270 lines
16 KiB
TypeScript
270 lines
16 KiB
TypeScript
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<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 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('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 and deletes failed 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.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<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('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
|
|
});
|
|
});
|
|
});
|