Assessor_2.0/tests/api/assessor-api-client.test.ts

321 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
const timestamp = '2026-09-06T10:00:00.000Z';
describe('AssessorApiClient', () => {
it('uses the versioned same-origin API and adds the mutation header only to writes', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(JSON.stringify({
status: 'ok', application: 'ASSESSOR', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ project: { id: 'p-1' }, repositoryRevision: 1 }), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
const client = new AssessorApiClient({ fetcher });
await client.health();
await client.saveProject({
id: 'p-1', name: 'Projekt', createdAt: '2026-07-17T08:00:00.000Z',
updatedAt: '2026-07-17T08:00:00.000Z', attributes: {}
});
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/health');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false);
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/projects');
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1');
});
it('loads, updates and deletes tenant data through project endpoints', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(JSON.stringify({ version: 1, project: { id: 'tenant-1' }, assessments: [], scanRuns: [], importFiles: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ deleted: true, repositoryRevision: 2 }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.exportProject('tenant-1');
await client.deleteProject('tenant-1');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/tenant-1/export');
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/projects/tenant-1');
expect(fetcher.mock.calls[1][1]?.method).toBe('DELETE');
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({ projectId: 'tenant-1', cascade: true });
});
it('loads and saves complete finding reviews through the database API', async () => {
const review = {
id: 'review-1', target: { kind: 'occurrence' as const, occurrenceId: 'occurrence/1' }, workflowStatus: 'open' as const,
workflowLabel: 'Open', priority: 'medium' as const, includeInActionPlan: true, owner: 'Security Team',
dueAt: '2026-10-15', comment: 'Synthetic note', prerequisites: 'Window', requiredAccess: 'Admin',
nextStep: 'Remediate', acceptanceEvidence: 'Retest', markers: ['quick-win'],
createdAt: timestamp, updatedAt: timestamp, revision: 1
};
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(JSON.stringify([review]), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ review, repositoryRevision: 4 }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.listProjectReviews('project/one');
await client.saveReview(review, 0);
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/reviews');
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/reviews');
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({ review, expectedRevision: 0 });
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1');
});
it('loads the environment snapshot with assessment and repeated scan-run filters', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
version: 1, projectId: 'tenant/1', scanRunIds: [], generatedAt: '2026-08-23T00:00:00.000Z',
sections: [], observations: [], coverage: { observed: 0, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.getEnvironmentSnapshot({ projectId: 'tenant/1', assessmentId: 'assessment/1', scanRunIds: ['run/1', 'run/2'] });
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/tenant%2F1/environment-snapshot?assessmentId=assessment%2F1&scanRunIds=run%2F1&scanRunIds=run%2F2');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false);
});
it('keeps LiteLLM behind the same-origin project API', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
version: 1, projectId: 'project/one', scope: { assessmentIds: ['assessment/one'], scanRunIds: ['run/one'] },
generationEnabled: true, state: 'empty', stale: false
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.getAiOverview('project/one', ['assessment/one'], ['run/one']);
await client.generateAiOverview('project/one', {
assessmentIds: ['assessment/one'], scanRunIds: ['run/one'], idempotencyKey: 'request-1234'
});
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/ai-overview?assessmentIds=assessment%2Fone&scanRunIds=run%2Fone');
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/projects/project%2Fone/ai-overview');
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1');
});
it('submits GPO backups only through the project-bound mutation endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
version: 1, artifactId: 'artifact-1', jobId: 'job-1', sha256: 'a'.repeat(64), sizeBytes: 3,
status: 'queued', deduplicated: false, repositoryRevision: 1
}), { status: 202, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
const bytes = new Uint8Array([1, 2, 3]);
const file = { name: 'backup.zip', size: bytes.length, arrayBuffer: async () => bytes.buffer } as File;
await client.submitGpoBackup('project/one', file);
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/gpo-backups');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).get('X-Assessor-Request')).toBe('1');
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
fileName: 'backup.zip', sizeBytes: 3, bytesBase64: 'AQID'
});
});
it('loads GPO processing status through the read-only project endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
version: 1, projectId: 'project/one', generatedAt: '2026-07-21T10:00:00.000Z', records: []
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.listProjectGpoBackupStatus('project/one');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/gpo-backups/status');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false);
});
it('reads policy analyses and requests deterministic GPO and baseline reprocessing', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify({ version: 1, records: [] }), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
const client = new AssessorApiClient({ fetcher });
await client.listProjectGpoPolicyAnalyses('project/one');
await client.reprocessGpoBackup('project/one', 'artifact/one', 'run/one');
await client.listMicrosoftSecurityBaselineAnalyses('baseline/one');
await client.reprocessMicrosoftSecurityBaseline('baseline/one');
await client.deleteMicrosoftSecurityBaseline('baseline/one');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/policy-analyses');
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/projects/project%2Fone/gpo-backups/artifact%2Fone/reprocess');
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({ expectedLatestProcessingRunId: 'run/one' });
expect(fetcher.mock.calls[2][0]).toBe('/api/v1/reference-data/microsoft-security-baselines/baseline%2Fone/analysis');
expect(fetcher.mock.calls[3][0]).toBe('/api/v1/reference-data/microsoft-security-baselines/baseline%2Fone/reprocess');
expect(fetcher.mock.calls[4][0]).toBe('/api/v1/reference-data/microsoft-security-baselines/baseline%2Fone');
expect(fetcher.mock.calls[4][1]?.method).toBe('DELETE');
expect(JSON.parse(String(fetcher.mock.calls[4][1]?.body))).toEqual({ confirmDelete: true });
});
it('creates and reads environment profiles, snapshot aggregations, evaluations, and policy findings through project-bound endpoints', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ version: 1, records: [] }), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
{
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
version: 1,
catalog: {
version: 1,
id: 'policy-evaluation-rule-catalog-1.0.0',
catalog: { id: 'policy-evaluation-rule-catalog', version: '1.0.0' },
status: 'released',
rules: []
}
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.getPolicyEvaluationRuleCatalog();
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/reference-data/policy-evaluation-rule-catalog');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false);
}
const client = new AssessorApiClient({ fetcher });
const profileRequest = {
name: 'Windows Server 2022',
contexts: [{
id: 'windows-server-2022', kind: 'operating-system' as const,
productFamily: 'Windows Server', productVersion: '2022', role: 'Member Server', scope: 'computer' as const
}]
};
const aggregationRequest = { environmentProfileId: 'profile/one', sourceAnalysisRunIds: ['analysis/one'] };
const evaluationRequest = { aggregationRunId: 'aggregation/one', baselineAnalysisIds: ['baseline-analysis/one'] };
await client.listEnvironmentProfiles('project/one');
await client.createEnvironmentProfile('project/one', profileRequest);
await client.listPolicySnapshotAggregations('project/one');
await client.aggregatePolicySnapshot('project/one', aggregationRequest);
await client.listPolicyEvaluations('project/one');
await client.evaluatePolicy('project/one', evaluationRequest);
await client.listPolicyFindingRuns('project/one');
await client.createPolicyFindingRun('project/one', { evaluationRunId: 'evaluation/one' });
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/environment-profiles');
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/projects/project%2Fone/environment-profiles');
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual(profileRequest);
expect(fetcher.mock.calls[2][0]).toBe('/api/v1/projects/project%2Fone/policy-snapshot-aggregations');
expect(fetcher.mock.calls[3][0]).toBe('/api/v1/projects/project%2Fone/policy-snapshot-aggregations');
expect(JSON.parse(String(fetcher.mock.calls[3][1]?.body))).toEqual(aggregationRequest);
expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false);
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1');
expect(fetcher.mock.calls[4][0]).toBe('/api/v1/projects/project%2Fone/policy-evaluations');
expect(fetcher.mock.calls[5][0]).toBe('/api/v1/projects/project%2Fone/policy-evaluations');
expect(JSON.parse(String(fetcher.mock.calls[5][1]?.body))).toEqual(evaluationRequest);
expect(fetcher.mock.calls[6][0]).toBe('/api/v1/projects/project%2Fone/policy-finding-runs');
expect(fetcher.mock.calls[7][0]).toBe('/api/v1/projects/project%2Fone/policy-finding-runs');
expect(JSON.parse(String(fetcher.mock.calls[7][1]?.body))).toEqual({ evaluationRunId: 'evaluation/one' });
expect(new Headers(fetcher.mock.calls[3][1]?.headers).get('X-Assessor-Request')).toBe('1');
expect(new Headers(fetcher.mock.calls[5][1]?.headers).get('X-Assessor-Request')).toBe('1');
expect(new Headers(fetcher.mock.calls[7][1]?.headers).get('X-Assessor-Request')).toBe('1');
});
it('retries failed imports and deletes GPO imports through exact project-bound mutations', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(JSON.stringify({ version: 1, artifactId: 'artifact/one', jobId: 'job-1', status: 'queued', attempts: 0 }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ version: 1, artifactId: 'artifact/one', deleted: true, storageRemoval: 'complete' }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.retryGpoBackup('project/one', 'artifact/one');
await client.deleteGpoBackup('project/one', 'artifact/one');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/gpo-backups/artifact%2Fone/retry');
expect(fetcher.mock.calls[0][1]?.method).toBe('POST');
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/projects/project%2Fone/gpo-backups/artifact%2Fone');
expect(fetcher.mock.calls[1][1]?.method).toBe('DELETE');
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({ artifactId: 'artifact/one', confirmDelete: true });
});
it('turns structured server errors into typed client errors', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
error: { code: 'repository_conflict', message: 'Konflikt', requestId: 'request-7' }
}), { status: 409, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await expect(client.listProjects()).rejects.toMatchObject({
status: 409, code: 'repository_conflict', requestId: 'request-7', message: 'Konflikt'
});
});
it('previews and completes import batches through the versioned workflow endpoints', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(JSON.stringify({ version: 1, batchId: 'batch-1', createdAt: 'now', entries: [] }), {
status: 200, headers: { 'Content-Type': 'application/json' }
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ batchId: 'batch-1', fingerprints: [], repositoryRevision: 1 }), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
const client = new AssessorApiClient({ fetcher });
const bytes = new TextEncoder().encode('Name\nFinding');
const file = {
name: 'findings.csv', size: bytes.length, lastModified: 0, type: 'text/csv',
arrayBuffer: async () => bytes.buffer
} as File;
await client.previewImportBatch([file], 'auto');
await client.completeImportBatch('batch-1', [{ entryId: 'batch-1:0', sha256: 'a'.repeat(64), fileName: file.name, sizeBytes: file.size, lastModified: 0 }]);
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/import-batches/preview');
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toMatchObject({ files: [{ fileName: 'findings.csv', bytesBase64: expect.any(String) }] });
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/import-batches/complete');
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1');
});
it('queries the unified result read API with repeated facet values and encoded detail IDs', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify({ total: 0, items: [], facets: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}));
const client = new AssessorApiClient({ fetcher });
await client.queryUnifiedResults({
projectId: 'project/one',
scanRunIds: ['run/one', 'run/two'],
severity: ['critical', 'high'],
sourceKind: ['environment-observation'],
environmentProperty: ['domain.functional_level'],
environmentState: ['observed']
});
await client.queryUnifiedResultFacets({ sourceTool: ['pingcastle'] });
await client.listResultCategories({ categoryNamespace: ['pingcastle'] });
await client.getUnifiedResultById('scanner-finding:occurrence/one');
expect(fetcher.mock.calls[0][0]).toBe(
'/api/v1/results?projectId=project%2Fone&scanRunIds=run%2Fone&scanRunIds=run%2Ftwo&severity=critical&severity=high&sourceKind=environment-observation&environmentProperty=domain.functional_level&environmentState=observed'
);
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/results/facets?sourceTool=pingcastle');
expect(fetcher.mock.calls[2][0]).toBe('/api/v1/results/categories?categoryNamespace=pingcastle');
expect(fetcher.mock.calls[3][0]).toBe('/api/v1/results/scanner-finding%3Aoccurrence%2Fone');
fetcher.mock.calls.forEach(call => {
expect(new Headers(call[1]?.headers).has('X-Assessor-Request')).toBe(false);
});
});
it('deletes an exact scan run only through its project-bound endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
deleted: true, repositoryRevision: 3
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.deleteScanRun('project/one', 'run/one');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/scan-runs/run%2Fone');
expect(fetcher.mock.calls[0][1]?.method).toBe('DELETE');
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
projectId: 'project/one', scanRunId: 'run/one', cascade: true
});
});
it('binds a source recovery upload to the original project, timeslot and artifact', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 202 }));
const client = new AssessorApiClient({ fetcher });
const bytes = new Uint8Array([1, 2, 3]);
const file = { name: 'renamed.zip', size: bytes.length, arrayBuffer: async () => bytes.buffer } as File;
await client.submitGpoBackup('project/one', file, 'slot/one', 'source/one');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/gpo-backups');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).get('X-Assessor-Request')).toBe('1');
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
fileName: 'renamed.zip', sizeBytes: 3, bytesBase64: 'AQID', assessmentId: 'slot/one', restoreArtifactId: 'source/one'
});
});
it('deletes an exact assessment only through its project-bound endpoint', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
deleted: true, repositoryRevision: 4
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.deleteAssessment('project/one', 'assessment/one');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/projects/project%2Fone/assessments/assessment%2Fone');
expect(fetcher.mock.calls[0][1]?.method).toBe('DELETE');
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
projectId: 'project/one', assessmentId: 'assessment/one', cascade: true
});
});
});