import { describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; describe('AssessorApiClient', () => { it('uses the versioned same-origin API and adds the mutation header only to writes', async () => { const fetcher = vi.fn() .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() .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 the environment snapshot with assessment and repeated scan-run filters', async () => { const fetcher = vi.fn().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('submits GPO backups only through the project-bound mutation endpoint', async () => { const fetcher = vi.fn().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().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() .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().mockResolvedValue(new Response(JSON.stringify({ version: 1, records: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })); { const fetcher = vi.fn().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 and deletes failed GPO imports through exact project-bound mutations', async () => { const fetcher = vi.fn() .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.deleteFailedGpoBackup('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().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() .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() .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().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('deletes an exact assessment only through its project-bound endpoint', async () => { const fetcher = vi.fn().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 }); }); it('reads and refreshes shared CVE enrichment through same-origin endpoints', async () => { const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ version: 1, record: { cveId: 'CVE-2012-3037' } }), { status: 200, headers: { 'Content-Type': 'application/json' } })); const client = new AssessorApiClient({ fetcher }); await client.getCveEnrichment('CVE-2012-3037'); await client.enrichCve('CVE-2012-3037'); expect(fetcher.mock.calls[0][0]).toBe('/api/v1/cves/CVE-2012-3037'); expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false); expect(fetcher.mock.calls[1][0]).toBe('/api/v1/cves/CVE-2012-3037/enrich'); expect(fetcher.mock.calls[1][1]?.method).toBe('POST'); expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1'); }); });