Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<
Expand Down Expand Up @@ -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);
}

/**
Expand Down
84 changes: 84 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
85 changes: 85 additions & 0 deletions src/resources/reports.ts
Original file line number Diff line number Diff line change
@@ -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<HttpClient>) {}

/** List the report type catalog. `GET /beta/reports/types` */
async types(): Promise<ReportType[]> {
const client = await this.getClient();
const data = await client.request<ReportType[]>('/beta/reports/types');
return Array.isArray(data) ? data : data ? [data] : [];
}

/** List queued/completed report runs. `GET /beta/reports/runs` */
async listRuns(): Promise<ReportRun[]> {
const client = await this.getClient();
const data = await client.request<ReportRun[]>('/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<ReportRunQueueResult> {
const client = await this.getClient();
const resolvedTenants = await Promise.all(
tenantIds.map((t) => resolveTenantId(client, t))
);
return client.request<ReportRunQueueResult>('/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<ReportRunOutputsProbe> {
const client = await this.getClient();
try {
const data = await client.request<ReportRunOutput[] | ReportRunOutput>(
`/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<ReportOutputDownload> {
const client = await this.getClient();
return client.requestBinary(`/beta/reports/runs/${runId}/outputs/${outputId}`);
}
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
66 changes: 66 additions & 0 deletions src/types/reports.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

/** 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;
}
25 changes: 25 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } = {}
): {
ok: boolean;
status: number;
headers: Headers;
text: () => Promise<string>;
arrayBuffer: () => Promise<ArrayBuffer>;
} {
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<T>(data: T, extra: Record<string, unknown> = {}): Record<string, unknown> {
return { success: true, message: '', errors: [], data, ...extra };
Expand Down
Loading