diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d785ec..60d10ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `groups` — `listByTenant` (search + pagination), `get`. - `roles` — `listByTenant`. - `auditEvents` — `listEventTypes`, `search` (`POST` with JSON body, preserving `continuationToken`). - - `assessments` — `list`, `run` (the only mutating method). + - `assessments` — `list`, `run` (mutating). + - `reports` — `types`, `listRuns`, `run` (mutating — queues a run across one or more tenants), + `outputs` (poll a run to terminal; normalizes the API's 200/404 polling contract into + `{ isTerminal, outputs }`), `downloadOutput` (raw bytes via `HttpClient.requestBinary`). - `resolveTenantId` helper and `InforcerClient.resolveTenantId` for the Client Tenant ID (integer) vs Azure AD tenant GUID distinction. - Standard envelope unwrapping (`{ success, message, errors, data }`) with `preserveFullResponse` diff --git a/src/client.ts b/src/client.ts index b5b3e47..e3d9d1c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,7 @@ import { GroupsResource } from './resources/groups.js'; import { RolesResource } from './resources/roles.js'; import { AuditEventsResource } from './resources/auditEvents.js'; import { AssessmentsResource } from './resources/assessments.js'; +import { ReportsResource } from './resources/reports.js'; /** * Client for the Inforcer REST API. @@ -34,6 +35,7 @@ export class InforcerClient { readonly roles: RolesResource; readonly auditEvents: AuditEventsResource; readonly assessments: AssessmentsResource; + readonly reports: ReportsResource; private httpClient: HttpClient | null = null; private readonly config: Required< @@ -69,6 +71,7 @@ export class InforcerClient { this.roles = new RolesResource(getClient); this.auditEvents = new AuditEventsResource(getClient); this.assessments = new AssessmentsResource(getClient); + this.reports = new ReportsResource(getClient); } /** diff --git a/src/http.ts b/src/http.ts index 86e2b5b..e286dab 100644 --- a/src/http.ts +++ b/src/http.ts @@ -163,6 +163,90 @@ export class HttpClient { throw lastError ?? new InforcerError('Request failed after retries'); } + /** + * Download raw bytes from an endpoint that returns a file (not a JSON + * envelope) — e.g. `GET /beta/reports/runs/{runId}/outputs/{outputId}`. + * Retries on 5xx like {@link request}; does not JSON-parse the body. + */ + async requestBinary(path: string): Promise<{ + data: ArrayBuffer; + contentType: string | null; + fileName: string | null; + }> { + let endpoint = path.trim(); + if (!endpoint.startsWith('/')) endpoint = `/${endpoint}`; + const url = `${this.baseUrl}${endpoint}`; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + if (attempt > 0) { + const delay = Math.min(1000 * 2 ** (attempt - 1) + Math.random() * 1000, 300_000); + await new Promise((r) => setTimeout(r, delay)); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + let response: Response; + try { + response = await this.fetchImpl(url, { + method: 'GET', + headers: { 'Inf-Api-Key': this.apiKey }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + } catch (err) { + clearTimeout(timeoutId); + let e = err as Error; + if (e.name === 'AbortError') { + e = new InforcerError(`Request timeout after ${this.timeout}ms`); + } + lastError = e; + if (attempt < this.maxRetries) continue; + throw e; + } + + if (!response.ok) { + if (response.status >= 500 && attempt < this.maxRetries) { + const rawText = await response.text().catch(() => ''); + lastError = this.buildError(response.status, undefined, rawText); + continue; + } + const rawText = await response.text().catch(() => ''); + let envelope: ApiEnvelope | undefined; + try { + envelope = rawText ? (JSON.parse(rawText) as ApiEnvelope) : undefined; + } catch { + envelope = undefined; + } + throw this.buildError(response.status, envelope, rawText); + } + + const data = await response.arrayBuffer(); + const contentType = response.headers.get('content-type'); + const fileName = this.parseFileName(response.headers.get('content-disposition')); + return { data, contentType, fileName }; + } + + throw lastError ?? new InforcerError('Request failed after retries'); + } + + /** Extract the `filename` parameter from a `Content-Disposition` header value. */ + private parseFileName(header: string | null): string | null { + if (!header) return null; + const starMatch = /filename\*\s*=\s*[^']*''([^;]+)/i.exec(header); + if (starMatch) { + try { + return decodeURIComponent(starMatch[1].trim()); + } catch { + return starMatch[1].trim(); + } + } + const match = /filename\s*=\s*"?([^";]+)"?/i.exec(header); + return match ? match[1].trim() : null; + } + /** * If `data` is a plain object whose only meaningful array property holds the * payload (e.g. `{ value: [...] }`), unwrap to that array. Mirrors the tail of diff --git a/src/index.ts b/src/index.ts index 6827f55..20a3337 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { RolesResource } from './resources/roles.js'; export { AuditEventsResource } from './resources/auditEvents.js'; export type { AuditEventSearchResult } from './resources/auditEvents.js'; export { AssessmentsResource } from './resources/assessments.js'; +export { ReportsResource } from './resources/reports.js'; export * from './types/index.js'; export * from './errors.js'; diff --git a/src/resources/reports.ts b/src/resources/reports.ts new file mode 100644 index 0000000..a7428b2 --- /dev/null +++ b/src/resources/reports.ts @@ -0,0 +1,85 @@ +import type { HttpClient } from '../http.js'; +import { NotFoundError } from '../errors.js'; +import { resolveTenantId } from '../tenant-resolver.js'; +import type { TenantIdInput } from '../types/common.js'; +import type { + ReportType, + ReportRun, + ReportRunRequestEntry, + ReportRunQueueResult, + ReportRunOutputsProbe, + ReportRunOutput, + ReportOutputDownload, +} from '../types/reports.js'; + +export class ReportsResource { + constructor(private getClient: () => Promise) {} + + /** List the report type catalog. `GET /beta/reports/types` */ + async types(): Promise { + const client = await this.getClient(); + const data = await client.request('/beta/reports/types'); + return Array.isArray(data) ? data : data ? [data] : []; + } + + /** List queued/completed report runs. `GET /beta/reports/runs` */ + async listRuns(): Promise { + const client = await this.getClient(); + const data = await client.request('/beta/reports/runs'); + return Array.isArray(data) ? data : data ? [data] : []; + } + + /** + * Queue one or more reports across one or more tenants. `POST /beta/reports/runs` + * + * Each tenant identifier accepts a numeric Client Tenant ID, an Azure AD tenant + * GUID, a tenant DNS name, or a friendly name — resolved to the numeric Client + * Tenant ID before the request, mirroring {@link AssessmentsResource.run}. + */ + async run( + reports: ReportRunRequestEntry[], + tenantIds: TenantIdInput[] + ): Promise { + const client = await this.getClient(); + const resolvedTenants = await Promise.all( + tenantIds.map((t) => resolveTenantId(client, t)) + ); + return client.request('/beta/reports/runs', { + method: 'POST', + body: { reports, tenants: { includeTenants: resolvedTenants } }, + preserveStructure: true, + }); + } + + /** + * Probe whether a run has finished. `GET /beta/reports/runs/{runId}/outputs` + * + * The API returns 200 with the output list once the run is terminal, and 404 + * while it is still pending — normalized here into `{ isTerminal, outputs }` + * so callers don't need to catch {@link NotFoundError} themselves. + */ + async outputs(runId: string): Promise { + const client = await this.getClient(); + try { + const data = await client.request( + `/beta/reports/runs/${runId}/outputs` + ); + const outputs = Array.isArray(data) ? data : data ? [data] : []; + return { isTerminal: true, outputs }; + } catch (err) { + if (err instanceof NotFoundError) { + return { isTerminal: false, outputs: [] }; + } + throw err; + } + } + + /** + * Download a single report output's raw bytes. + * `GET /beta/reports/runs/{runId}/outputs/{outputId}` + */ + async downloadOutput(runId: string, outputId: string): Promise { + const client = await this.getClient(); + return client.requestBinary(`/beta/reports/runs/${runId}/outputs/${outputId}`); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 92c6a53..2f25858 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,3 +9,4 @@ export * from './groups.js'; export * from './roles.js'; export * from './assessments.js'; export * from './secure-scores.js'; +export * from './reports.js'; diff --git a/src/types/reports.ts b/src/types/reports.ts new file mode 100644 index 0000000..2e2690f --- /dev/null +++ b/src/types/reports.ts @@ -0,0 +1,66 @@ +/** + * A report type catalog entry from `GET /beta/reports/types`. Field set is not + * formally documented by the community module; common fields are typed here + * and unknown extras preserved. + */ +export interface ReportType { + key: string; + tags?: string[]; + collatable?: boolean; + supportedOutputFormats?: string[]; + [key: string]: unknown; +} + +/** A single report request within a `POST /beta/reports/runs` body. */ +export interface ReportRunRequestEntry { + type: string; + outputFormat: string; + collate?: boolean; + parameters?: Record; +} + +/** Body of `POST /beta/reports/runs`. */ +export interface ReportRunRequest { + reports: ReportRunRequestEntry[]; + tenants: { includeTenants: number[] }; +} + +/** + * Response from queuing a report run. Shape not formally documented by the + * community module; passed through as-is. + */ +export interface ReportRunQueueResult { + [key: string]: unknown; +} + +/** A report run record from `GET /beta/reports/runs`. */ +export interface ReportRun { + runId?: string; + id?: string; + status?: string; + [key: string]: unknown; +} + +/** A single downloadable output produced by a completed report run. */ +export interface ReportRunOutput { + id?: string; + outputId?: string; + [key: string]: unknown; +} + +/** + * Result of probing `GET /beta/reports/runs/{runId}/outputs`. The endpoint + * returns 200 with the outputs once the run is terminal, and 404 while it is + * still pending — this shape normalizes both into one result. + */ +export interface ReportRunOutputsProbe { + isTerminal: boolean; + outputs: ReportRunOutput[]; +} + +/** Raw bytes downloaded from `GET /beta/reports/runs/{runId}/outputs/{outputId}`. */ +export interface ReportOutputDownload { + data: ArrayBuffer; + contentType: string | null; + fileName: string | null; +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 0e34a32..2ec5e08 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -17,6 +17,31 @@ export function mockResponse( }; } +/** + * Build a minimal mock binary `Response` for {@link HttpClient.requestBinary} + * fetch mocks — reads via `response.arrayBuffer()` and `response.headers`. + */ +export function mockBinaryResponse( + bytes: Uint8Array, + init: { ok?: boolean; status?: number; headers?: Record } = {} +): { + ok: boolean; + status: number; + headers: Headers; + text: () => Promise; + arrayBuffer: () => Promise; +} { + const status = init.status ?? 200; + const ok = init.ok ?? (status >= 200 && status < 300); + return { + ok, + status, + headers: new Headers(init.headers ?? {}), + text: async () => new TextDecoder().decode(bytes), + arrayBuffer: async () => bytes.buffer as ArrayBuffer, + }; +} + /** Wrap a payload in the standard Inforcer success envelope. */ export function envelope(data: T, extra: Record = {}): Record { return { success: true, message: '', errors: [], data, ...extra }; diff --git a/tests/resources/reports.test.ts b/tests/resources/reports.test.ts new file mode 100644 index 0000000..f19c823 --- /dev/null +++ b/tests/resources/reports.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ReportsResource } from '../../src/resources/reports.js'; +import { HttpClient } from '../../src/http.js'; +import { mockResponse, mockBinaryResponse, envelope } from '../helpers.js'; + +describe('ReportsResource', () => { + let mockFetch: ReturnType; + let resource: ReportsResource; + + beforeEach(() => { + mockFetch = vi.fn(); + const client = new HttpClient({ + baseUrl: 'https://api-uk.inforcer.com/api', + apiKey: 'key', + timeout: 5000, + maxRetries: 0, + fetchImpl: mockFetch as unknown as typeof fetch, + }); + resource = new ReportsResource(async () => client); + }); + + it('lists the report type catalog via GET /beta/reports/types', async () => { + const types = [{ key: 'CopilotAdoption', supportedOutputFormats: ['csv', 'pdf'] }]; + mockFetch.mockResolvedValueOnce(mockResponse(envelope(types))); + + const result = await resource.types(); + + expect(result).toEqual(types); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api-uk.inforcer.com/api/beta/reports/types', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('lists report runs via GET /beta/reports/runs', async () => { + const runs = [{ runId: 'r1', status: 'completed' }]; + mockFetch.mockResolvedValueOnce(mockResponse(envelope(runs))); + + const result = await resource.listRuns(); + + expect(result).toEqual(runs); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api-uk.inforcer.com/api/beta/reports/runs', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('queues a run via POST /beta/reports/runs, resolving numeric tenant IDs as-is', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(envelope({ runId: 'r1' }))); + + const result = await resource.run( + [{ type: 'CopilotAdoption', outputFormat: 'csv' }], + [482, 139] + ); + + expect(result).toEqual({ runId: 'r1' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api-uk.inforcer.com/api/beta/reports/runs'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + reports: [{ type: 'CopilotAdoption', outputFormat: 'csv' }], + tenants: { includeTenants: [482, 139] }, + }); + }); + + it('outputs() reports isTerminal:true with the output list on 200', async () => { + const outputs = [{ id: 'o1' }]; + mockFetch.mockResolvedValueOnce(mockResponse(envelope(outputs))); + + const result = await resource.outputs('run-1'); + + expect(result).toEqual({ isTerminal: true, outputs }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api-uk.inforcer.com/api/beta/reports/runs/run-1/outputs', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('outputs() reports isTerminal:false with no outputs on 404', async () => { + mockFetch.mockResolvedValueOnce( + mockResponse(envelope(null, { success: false, errorCode: 'notFound' }), { status: 404 }) + ); + + const result = await resource.outputs('run-1'); + + expect(result).toEqual({ isTerminal: false, outputs: [] }); + }); + + it('downloadOutput() returns raw bytes, content type, and filename', async () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + mockFetch.mockResolvedValueOnce( + mockBinaryResponse(bytes, { + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="report.pdf"', + }, + }) + ); + + const result = await resource.downloadOutput('run-1', 'output-1'); + + expect(new Uint8Array(result.data)).toEqual(bytes); + expect(result.contentType).toBe('application/pdf'); + expect(result.fileName).toBe('report.pdf'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api-uk.inforcer.com/api/beta/reports/runs/run-1/outputs/output-1', + expect.objectContaining({ method: 'GET' }) + ); + }); +});