diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 7d8df7ee0..444923c2c 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -194,6 +194,12 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getById()` | `Traces.Api` | | `getSpansByIds()` | `Traces.Api` | +## Governance + +| Method | OAuth Scope | +|--------|-------------| +| `getPolicyTraces()` | `Insights.RealTimeData Insights OR.Folders.Read` | + ## Processes | Method | OAuth Scope | diff --git a/docs/pagination.md b/docs/pagination.md index ba2e1173a..8edbd6cf7 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -136,3 +136,4 @@ console.log(`Total count: ${allAssets.totalCount}`); | Feedback | `getCategories()` | ✅ Yes | | Traces | `getById()` | ❌ No | | Traces | `getSpansByIds()` | ❌ No | +| Governance | `getPolicyTraces()` | ✅ Yes | diff --git a/mkdocs.yml b/mkdocs.yml index f52f49d82..ba5de4b3e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -195,6 +195,7 @@ nav: - Entities: - api/interfaces/entity/index.md - Choice Sets: api/interfaces/ChoiceSetServiceModel.md + - Governance: api/interfaces/GovernanceServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md - Process Instances: api/interfaces/ProcessInstancesServiceModel.md diff --git a/package.json b/package.json index a4affbe31..828652ade 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,16 @@ "types": "./dist/document-understanding/index.d.ts", "default": "./dist/document-understanding/index.cjs" } + }, + "./governance": { + "import": { + "types": "./dist/governance/index.d.ts", + "default": "./dist/governance/index.mjs" + }, + "require": { + "types": "./dist/governance/index.d.ts", + "default": "./dist/governance/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 50167037b..3efd3fdb0 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -208,6 +208,11 @@ const serviceEntries = [ name: 'document-understanding', input: 'src/models/document-understanding/index.ts', output: 'document-understanding/index' + }, + { + name: 'governance', + input: 'src/services/governance/index.ts', + output: 'governance/index' } ]; diff --git a/src/index.ts b/src/index.ts index 84d26f547..dcdde3f80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export * from './models/action-center'; export * from './models/conversational-agent'; export * from './models/agents'; export * from './models/document-understanding'; +export * from './models/governance'; // Export error handling functionality (public API only) export * from './core/errors'; diff --git a/src/models/governance/governance.internal-types.ts b/src/models/governance/governance.internal-types.ts new file mode 100644 index 000000000..d7c9aee72 --- /dev/null +++ b/src/models/governance/governance.internal-types.ts @@ -0,0 +1,23 @@ +/** + * Raw policy evaluation trace item as returned by API before transformation. + */ +export interface RawGovernancePolicyTraceItem { + tenantId?: string; + startTime: string; + finalEnforcement?: string; + policyId?: string; + policyEnforcement?: string; + policyEvaluationResult?: string; + policyName?: string; + policyStatus?: string; + policyEvaluationDetails?: string; + actorProcessId?: string; + actorProcessType?: string; + actorIdentityId?: string; + resourceId?: string; + resourceType?: string; + folderKey?: string; + traceId?: string; + processKey?: string; + jobKey?: string; +} diff --git a/src/models/governance/governance.models.ts b/src/models/governance/governance.models.ts new file mode 100644 index 000000000..417f9d968 --- /dev/null +++ b/src/models/governance/governance.models.ts @@ -0,0 +1,81 @@ +import type { + GovernancePolicyTrace, + GovernancePolicyTraceGetAllOptions, +} from './governance.types'; +import type { + PaginatedResponse, + NonPaginatedResponse, + HasPaginationOptions, +} from '../../utils/pagination'; + +/** + * Service for inspecting governance policy enforcement on the UiPath platform. + * + * See [Define governance policies](https://docs.uipath.com/automation-ops/automation-cloud/latest/user-guide/define-governance-policies) + * for how governance policies are configured in Automation Ops. + * + * All methods require the caller to be an organization administrator. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Governance } from '@uipath/uipath-typescript/governance'; + * + * const governance = new Governance(sdk); + * const traces = await governance.getPolicyTraces(new Date('2024-01-01')); + * ``` + */ +export interface GovernanceServiceModel { + /** + * Gets per-policy enforcement decisions across the requested time range. + * + * Each result row represents one policy's verdict within a single governance enforcement event. + * A single user action can produce multiple rows when multiple policies were consulted. + * Results are ordered by event start time, descending. + * + * @param startTime - Inclusive lower bound on the trace start time. + * @param options - Optional filters and pagination options + * @returns Promise resolving to {@link NonPaginatedResponse} of {@link GovernancePolicyTrace} + * without pagination options, or {@link PaginatedResponse} of + * {@link GovernancePolicyTrace} when pagination options are used. + * + * @example + * ```typescript + * import { Governance, PolicyEvaluationResult } from '@uipath/uipath-typescript/governance'; + * + * const governance = new Governance(sdk); + * + * // Get all policy traces from the specified start time + * const recent = await governance.getPolicyTraces(new Date('2024-01-01')); + * console.log(recent.items.length); + * + * // Get all denied decisions across the whole organization + * const page1 = await governance.getPolicyTraces( + * new Date('2024-01-01'), + * { + * endTime: new Date(), + * evaluationResult: [PolicyEvaluationResult.Deny, PolicyEvaluationResult.SimulatedDeny], + * fullOrganization: true, + * pageSize: 25, + * }, + * ); + * + * if (page1.hasNextPage) { + * const page2 = await governance.getPolicyTraces( + * new Date('2024-01-01'), + * { cursor: page1.nextCursor }, + * ); + * } + * ``` + */ + getPolicyTraces( + startTime: Date, + options?: T, + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; +} diff --git a/src/models/governance/governance.types.ts b/src/models/governance/governance.types.ts new file mode 100644 index 000000000..dae6f0740 --- /dev/null +++ b/src/models/governance/governance.types.ts @@ -0,0 +1,113 @@ +/** + * Governance Service Types + * + * Public types exposed via `@uipath/uipath-typescript/governance`. + */ + +import { PaginationOptions } from '../../utils/pagination/types'; + +export enum PolicyEvaluationResult { + /** Active policy permitted the action. */ + Allow = 'Allow', + /** Active policy blocked the action. */ + Deny = 'Deny', + /** Simulated (NoOp) policy would have permitted the action. */ + SimulatedAllow = 'SimulatedAllow', + /** Simulated (NoOp) policy would have blocked the action. */ + SimulatedDeny = 'SimulatedDeny', +} + +/** + * Each trace row represents one policy's verdict within a governance + * enforcement event. One enforcement event can produce multiple trace rows + * when multiple policies contributed to the final verdict. + */ +export interface GovernancePolicyTrace { + /** Tenant the trace was recorded in. Present even when `fullOrganization` is `true`. */ + tenantId?: string; + /** The start time of governance enforcement event. */ + startTime: string; + /** Final enforcement verdict for the parent governance event. */ + finalEnforcement?: string; + /** ID of the policy this trace row evaluates. */ + policyId?: string; + /** This individual policy's enforcement contribution to the parent verdict. */ + policyEnforcement?: string; + /** The outcome of one policy evaluation — whether it allowed or denied the action, and whether that decision was actively enforced or just simulated (NoOp). */ + policyEvaluationResult?: string; + /** Display name of the policy. */ + policyName?: string; + /** Enforcement mode of the policy at the time of evaluation. */ + policyStatus?: string; + /** Opaque details payload describing the evaluation result. */ + policyEvaluationDetails?: string; + /** Process or executable that triggered the evaluation. */ + actorProcessId?: string; + /** Type of the actor process (e.g. coded agent, RPA process). */ + actorProcessType?: string; + /** Identity (user/principal) that triggered the evaluation. */ + actorIdentityId?: string; + /** Resource being acted on. */ + resourceId?: string; + /** Type of the resource being acted on. */ + resourceType?: string; + /** Orchestrator folder key associated with the evaluation, if any. */ + folderKey?: string; + /** Distributed-tracing ID covering the governance enforcement event. */ + traceId?: string; + /** Process key associated with the evaluation, if any. */ + processKey?: string; + /** Job key associated with the evaluation, if any. */ + jobKey?: string; +} + +/** + * Common filter options shared across Governance APIs. + * + * Holds filters that are not specific to any single governance resource, so + * other governance endpoints can reuse them. + */ +export interface GovernanceFilterOptions { + /** + * Inclusive upper bound on trace start time. When omitted, the upper bound + * is open. + */ + endTime?: Date; + /** + * Whether to query the whole organization instead of just the current tenant. + * + * Defaults to tenant-scoped: + * - omitted → tenant-scoped (default) + * - `false` → tenant-scoped (explicit, same result) + * - `true` → org-wide across all tenants; requires an organization admin, + * otherwise the request returns 403 + * + * @default false + */ + fullOrganization?: boolean; +} + +/** + * Filter and pagination options for fetching policy traces. + * + * All filters combine with AND semantics. Array filters match any value in + * the array (OR within a single filter). + */ +export type GovernancePolicyTraceGetAllOptions = PaginationOptions & GovernanceFilterOptions & { + /** Filter by one or more policy evaluation results. */ + evaluationResult?: PolicyEvaluationResult[]; + /** Filter by one or more policy IDs. */ + policyId?: string[]; + /** Filter by one or more actor process IDs. */ + actorProcessId?: string[]; + /** Filter by one or more actor process types (e.g. coded agent, RPA process). */ + actorProcessType?: string[]; + /** Filter by one or more actor identity IDs. */ + actorIdentityId?: string[]; + /** Filter by one or more resource IDs. */ + resourceId?: string[]; + /** Filter by one or more resource types. */ + resourceType?: string[]; + /** Filter by one or more distributed-trace IDs. */ + traceId?: string[]; +}; diff --git a/src/models/governance/index.ts b/src/models/governance/index.ts new file mode 100644 index 000000000..11e4f360e --- /dev/null +++ b/src/models/governance/index.ts @@ -0,0 +1,8 @@ +/** + * Governance Types + * + * Public type surface for the Governance service. + */ + +export * from './governance.types'; +export * from './governance.models'; diff --git a/src/services/base.ts b/src/services/base.ts index 348d9e85a..6105c5613 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -237,6 +237,8 @@ export class BaseService { // When true (default), converts pageNumber to a skip/offset value (e.g., page 3 with pageSize 10 → skip 20). // When false, passes pageNumber directly as the offset param — used by APIs that accept a page number instead of a record offset. const convertToSkip = paginationParams?.convertToSkip ?? true; + // When true, sends pageNumber - 1 (for 0-based APIs). Default false (1-based). + const zeroBased = paginationParams?.zeroBased ?? false; requestParams[pageSizeParam] = limitedPageSize; if (convertToSkip) { @@ -244,7 +246,8 @@ export class BaseService { requestParams[offsetParam] = (params.pageNumber - 1) * limitedPageSize; } } else { - requestParams[offsetParam] = params.pageNumber || 1; + const sdkPageNumber = params.pageNumber || 1; + requestParams[offsetParam] = zeroBased ? sdkPageNumber - 1 : sdkPageNumber; } if (countParam) { requestParams[countParam] = true; diff --git a/src/services/governance/governance.ts b/src/services/governance/governance.ts new file mode 100644 index 000000000..1cc1c4968 --- /dev/null +++ b/src/services/governance/governance.ts @@ -0,0 +1,110 @@ +import { BaseService } from '../base'; +import { ValidationError } from '../../core/errors'; +import { GOVERNANCE_ENDPOINTS } from '../../utils/constants/endpoints'; +import { + HTTP_METHODS, + GOVERNANCE_PAGINATION, + GOVERNANCE_OFFSET_PARAMS, +} from '../../utils/constants/common'; +import { track } from '../../core/telemetry'; +import { + PaginatedResponse, + NonPaginatedResponse, + HasPaginationOptions, +} from '../../utils/pagination'; +import { PaginationHelpers } from '../../utils/pagination/helpers'; +import { PaginationType } from '../../utils/pagination/internal-types'; +import { + GovernancePolicyTrace, + GovernancePolicyTraceGetAllOptions, +} from '../../models/governance/governance.types'; +import { GovernanceServiceModel } from '../../models/governance/governance.models'; + +/** + * Service for inspecting governance policy enforcement on the UiPath platform. + */ +export class GovernanceService extends BaseService implements GovernanceServiceModel { + /** + * Gets per-policy enforcement decisions across the requested time range. + * + * Each result row represents one policy's verdict within a single governance enforcement event. + * A single user action can produce multiple rows when multiple policies were consulted. + * Results are ordered by event start time, descending. + * + * @param startTime - Inclusive lower bound on the trace start time. + * @param options - Optional filters and pagination options + * @returns Promise resolving to {@link NonPaginatedResponse} of {@link GovernancePolicyTrace} + * without pagination options, or {@link PaginatedResponse} of + * {@link GovernancePolicyTrace} when pagination options are used. + * + * @example + * ```typescript + * import { Governance, PolicyEvaluationResult } from '@uipath/uipath-typescript/governance'; + * + * const governance = new Governance(sdk); + * + * // Get all policy traces from the specified start time + * const recent = await governance.getPolicyTraces(new Date('2024-01-01')); + * console.log(recent.items.length); + * + * // Get all denied decisions across the whole organization + * const page1 = await governance.getPolicyTraces( + * new Date('2024-01-01'), + * { + * endTime: new Date(), + * evaluationResult: [PolicyEvaluationResult.Deny, PolicyEvaluationResult.SimulatedDeny], + * fullOrganization: true, + * pageSize: 25, + * }, + * ); + * + * if (page1.hasNextPage) { + * const page2 = await governance.getPolicyTraces( + * new Date('2024-01-01'), + * { cursor: page1.nextCursor }, + * ); + * } + * ``` + */ + @track('Governance.GetPolicyTraces') + async getPolicyTraces( + startTime: Date, + options?: T, + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + > { + if (!startTime) { + throw new ValidationError({ message: 'startTime is required for getPolicyTraces' }); + } + + const apiOptions = { + ...options, + startTime: startTime.toISOString(), + endTime: options?.endTime?.toISOString(), + }; + + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => GOVERNANCE_ENDPOINTS.POLICY.TRACES, + method: HTTP_METHODS.POST, + excludeFromPrefix: Object.keys(apiOptions), + pagination: { + paginationType: PaginationType.OFFSET, + itemsField: GOVERNANCE_PAGINATION.ITEMS_FIELD, + paginationParams: { + pageSizeParam: GOVERNANCE_OFFSET_PARAMS.PAGE_SIZE_PARAM, + offsetParam: GOVERNANCE_OFFSET_PARAMS.OFFSET_PARAM, + countParam: GOVERNANCE_OFFSET_PARAMS.COUNT_PARAM, + convertToSkip: false, + zeroBased: true, + }, + }, + }, apiOptions) as Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + } +} diff --git a/src/services/governance/index.ts b/src/services/governance/index.ts new file mode 100644 index 000000000..dd5d1dcbb --- /dev/null +++ b/src/services/governance/index.ts @@ -0,0 +1,23 @@ +/** + * Governance Module + * + * Provides access to UiPath governance policy evaluation traces. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Governance } from '@uipath/uipath-typescript/governance'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const governance = new Governance(sdk); + * const traces = await governance.getPolicyTraces(new Date('2024-01-01')); + * ``` + * + * @module + */ + +export { GovernanceService as Governance } from './governance'; + +export * from '../../models/governance'; diff --git a/src/utils/constants/common.ts b/src/utils/constants/common.ts index 319d2dc73..25688ac14 100644 --- a/src/utils/constants/common.ts +++ b/src/utils/constants/common.ts @@ -92,6 +92,28 @@ export const SLA_SUMMARY_OFFSET_PARAMS = { COUNT_PARAM: undefined }; +/** + * Governance pagination constants for page-number-based pagination + */ +export const GOVERNANCE_PAGINATION = { + /** Field name for items in governance response */ + ITEMS_FIELD: 'items' +}; + +/** + * Governance OFFSET pagination parameter names (page-number style, 0-based, no skip conversion) + */ +export const GOVERNANCE_OFFSET_PARAMS = { + /** Page size parameter name */ + PAGE_SIZE_PARAM: 'pageSize', + + /** Page number parameter name (sent directly, 0-based) */ + OFFSET_PARAM: 'pageNumber', + + /** No count param needed */ + COUNT_PARAM: undefined +}; + /** * Process Instance pagination constants for token-based pagination */ diff --git a/src/utils/constants/endpoints/governance.ts b/src/utils/constants/endpoints/governance.ts new file mode 100644 index 000000000..2f77f4bdd --- /dev/null +++ b/src/utils/constants/endpoints/governance.ts @@ -0,0 +1,16 @@ +/** + * Governance Service Endpoints + */ + +import { INSIGHTS_RTM_BASE } from './base'; + +/** + * Governance Service Endpoints + * Endpoints require an organization-admin caller + */ +export const GOVERNANCE_ENDPOINTS = { + POLICY: { + /** Policy evaluation traces (paginated). */ + TRACES: `${INSIGHTS_RTM_BASE}/Governance/policy/traces`, + }, +} as const; diff --git a/src/utils/constants/endpoints/index.ts b/src/utils/constants/endpoints/index.ts index 042fac2ea..ecd1d4064 100644 --- a/src/utils/constants/endpoints/index.ts +++ b/src/utils/constants/endpoints/index.ts @@ -26,3 +26,5 @@ export * from './feedback'; // Traces endpoints export * from './traces'; +// Governance endpoints +export * from './governance'; diff --git a/src/utils/pagination/helpers.ts b/src/utils/pagination/helpers.ts index 138dff0fa..b2f4c8237 100644 --- a/src/utils/pagination/helpers.ts +++ b/src/utils/pagination/helpers.ts @@ -324,7 +324,7 @@ export class PaginationHelpers { getEndpoint: config.getEndpoint, folderId, headers: config.headers, - paginationParams: cursor ? { cursor, pageSize } : jumpToPage ? { jumpToPage, pageSize } : { pageSize }, + paginationParams: cursor ? { cursor, pageSize } : jumpToPage !== undefined ? { jumpToPage, pageSize } : { pageSize }, additionalParams: prefixedOptions, transformFn: config.transformFn, method: config.method, diff --git a/src/utils/pagination/internal-types.ts b/src/utils/pagination/internal-types.ts index c70619a9a..c84e785b2 100644 --- a/src/utils/pagination/internal-types.ts +++ b/src/utils/pagination/internal-types.ts @@ -91,6 +91,7 @@ export interface GetAllPaginatedParams { tokenParam?: string; countParam?: string; convertToSkip?: boolean; + zeroBased?: boolean; }; }; } @@ -163,6 +164,7 @@ export interface RequestWithPaginationOptions extends RequestSpec { tokenParam?: string; countParam?: string; convertToSkip?: boolean; + zeroBased?: boolean; }; }; } @@ -201,6 +203,9 @@ export interface PaginationConfig { * When false, sends the pageNumber directly as the offset param value. * Only applies to OFFSET pagination type. */ convertToSkip?: boolean; + /** When true, sends `pageNumber - 1` as the offset param (for 0-based APIs). + * Default false (1-based). Only applies when `convertToSkip` is false. */ + zeroBased?: boolean; }; } diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index 8d8014067..f02b1d093 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -12,6 +12,7 @@ import { } from '../../../src/services/maestro'; import { Feedback } from '../../../src/services/agents/feedback'; import { Traces } from '../../../src/services/observability/traces'; +import { Governance } from '../../../src/services/governance'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -48,6 +49,7 @@ export interface TestServices { caseInstances: CaseInstancesService; feedback?: Feedback; traces?: Traces; + governance?: Governance; } /** @@ -129,6 +131,7 @@ function createV1Services(config: IntegrationConfig): TestServices { caseInstances: new CaseInstancesService(sdk), feedback: new Feedback(sdk), traces: new Traces(sdk), + governance: new Governance(sdk), }; } diff --git a/tests/integration/shared/governance/governance.integration.test.ts b/tests/integration/shared/governance/governance.integration.test.ts new file mode 100644 index 000000000..5fa2009fc --- /dev/null +++ b/tests/integration/shared/governance/governance.integration.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import { Governance } from '../../../../src/services/governance'; +import { PolicyEvaluationResult } from '../../../../src/models/governance/governance.types'; + +const modes: InitMode[] = ['v1']; + +// Skipped: the governance API is served by insightsrtm_, which rejects PAT tokens +// with 401 regardless of scopes and requires OAuth. Integration tests in CI +// authenticate with a PAT, so these would fail unconditionally. Re-enable by +// removing `.skip` once OAuth support is wired into the integration test harness. +describe.skip.each(modes)('Governance - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let governance!: Governance; + // Start time wide enough to cover historical traces in the test tenant. + const startTime = new Date('2024-01-01T00:00:00Z'); + + beforeAll(() => { + const service = getServices().governance; + if (!service) { + throw new Error('Governance service is not registered for this init mode'); + } + governance = service; + }); + + describe('getPolicyTraces', () => { + it('should retrieve traces without pagination options as a NonPaginatedResponse', async () => { + const result = await governance.getPolicyTraces(startTime); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should retrieve traces with pagination options as a PaginatedResponse', async () => { + const result = await governance.getPolicyTraces(startTime, { pageSize: 5 }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeLessThanOrEqual(5); + expect(result.currentPage).toBe(1); + expect(result.supportsPageJump).toBe(true); + expect(typeof result.hasNextPage).toBe('boolean'); + }); + + it('should support filtering by evaluationResult and fullOrganization', async () => { + const result = await governance.getPolicyTraces(startTime, { + evaluationResult: [PolicyEvaluationResult.Deny, PolicyEvaluationResult.SimulatedDeny], + fullOrganization: true, + pageSize: 5, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should round-trip a cursor to fetch the next page', async () => { + const page1 = await governance.getPolicyTraces(startTime, { pageSize: 1, fullOrganization: true }); + + if (!page1.hasNextPage || !page1.nextCursor) { + throw new Error( + 'Governance test tenant has fewer than 2 traces; cursor round-trip cannot be verified. Populate test data or widen startTime.', + ); + } + + const page2 = await governance.getPolicyTraces(startTime, { cursor: page1.nextCursor }); + expect(page2).toBeDefined(); + expect(Array.isArray(page2.items)).toBe(true); + expect(page2.currentPage).toBe(2); + }); + }); +}); diff --git a/tests/unit/services/governance/governance.test.ts b/tests/unit/services/governance/governance.test.ts new file mode 100644 index 000000000..b53e202fe --- /dev/null +++ b/tests/unit/services/governance/governance.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { GovernanceService } from '../../../../src/services/governance/governance'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { GOVERNANCE_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { GOVERNANCE_TEST_CONSTANTS, TEST_CONSTANTS } from '../../../utils/constants'; +import { + createMockRawGovernancePolicyTrace, + createMockRawGovernancePolicyTracesResponse, +} from '../../../utils/mocks/governance'; +import { + PolicyEvaluationResult, + GovernancePolicyTraceGetAllOptions, +} from '../../../../src/models/governance/governance.types'; +import type { PaginatedResponse } from '../../../../src/utils/pagination/types'; +import type { GovernancePolicyTrace } from '../../../../src/models/governance/governance.types'; + +vi.mock('../../../../src/core/http/api-client'); + +describe('GovernanceService Unit Tests', () => { + let governanceService: GovernanceService; + let mockApiClient: any; + const startTime = new Date(GOVERNANCE_TEST_CONSTANTS.START_TIME_ISO); + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient); + governanceService = new GovernanceService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getPolicyTraces - non-paginated', () => { + it('should return camelCase items wrapped in NonPaginatedResponse', async () => { + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse()); + + const result = await governanceService.getPolicyTraces(startTime); + + expect(result.items).toHaveLength(1); + const item = result.items[0]; + expect(item.tenantId).toBe(GOVERNANCE_TEST_CONSTANTS.TENANT_ID); + expect(item.policyId).toBe(GOVERNANCE_TEST_CONSTANTS.POLICY_ID); + expect(item.policyName).toBe(GOVERNANCE_TEST_CONSTANTS.POLICY_NAME); + expect(item.policyStatus).toBe(GOVERNANCE_TEST_CONSTANTS.POLICY_STATUS_ACTIVE); + expect(item.policyEvaluationResult).toBe(PolicyEvaluationResult.Deny); + expect(item.finalEnforcement).toBe('Deny'); + expect(item.traceId).toBe(GOVERNANCE_TEST_CONSTANTS.TRACE_ID); + expect(item.folderKey).toBe(GOVERNANCE_TEST_CONSTANTS.FOLDER_KEY); + // NonPaginatedResponse has no pagination fields + expect((result as PaginatedResponse).hasNextPage).toBeUndefined(); + }); + + it('should expose only camelCase fields (API returns camelCase, no transform applied)', async () => { + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse()); + + const result = await governanceService.getPolicyTraces(startTime); + const item = result.items[0]; + + const pascalKeys = Object.keys(item).filter((k) => /^[A-Z]/.test(k)); + expect(pascalKeys).toEqual([]); + }); + + it('should send required startTime, omit pagination params, and use endpoint', async () => { + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse()); + + await governanceService.getPolicyTraces(startTime); + + expect(mockApiClient.post).toHaveBeenCalledTimes(1); + const [endpoint, body] = mockApiClient.post.mock.calls[0]; + expect(endpoint).toBe(GOVERNANCE_ENDPOINTS.POLICY.TRACES); + expect(body.startTime).toBe(GOVERNANCE_TEST_CONSTANTS.START_TIME_ISO); + expect(body.pageNumber).toBeUndefined(); + expect(body.pageSize).toBeUndefined(); + }); + + it('should serialize endTime Date to ISO and pass array filters through', async () => { + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse()); + + const endTime = new Date(GOVERNANCE_TEST_CONSTANTS.END_TIME_ISO); + await governanceService.getPolicyTraces(startTime, { + endTime, + evaluationResult: [PolicyEvaluationResult.Deny, PolicyEvaluationResult.SimulatedDeny], + policyId: [GOVERNANCE_TEST_CONSTANTS.POLICY_ID], + fullOrganization: true, + }); + + const [, body] = mockApiClient.post.mock.calls[0]; + expect(body.endTime).toBe(GOVERNANCE_TEST_CONSTANTS.END_TIME_ISO); + expect(body.evaluationResult).toEqual(['Deny', 'SimulatedDeny']); + expect(body.policyId).toEqual([GOVERNANCE_TEST_CONSTANTS.POLICY_ID]); + expect(body.fullOrganization).toBe(true); + }); + + it('should return an empty items array when API returns no items', async () => { + mockApiClient.post.mockResolvedValue({}); + + const result = await governanceService.getPolicyTraces(startTime); + + expect(result.items).toEqual([]); + }); + }); + + describe('getPolicyTraces - paginated', () => { + it('should return PaginatedResponse with currentPage starting at 1 (SDK 1-based)', async () => { + mockApiClient.post.mockResolvedValue( + createMockRawGovernancePolicyTracesResponse([createMockRawGovernancePolicyTrace()]), + ); + + const result = await governanceService.getPolicyTraces(startTime, { + pageSize: TEST_CONSTANTS.PAGE_SIZE, + }) as PaginatedResponse; + + expect(result.currentPage).toBe(1); + expect(result.supportsPageJump).toBe(true); + expect(result.hasNextPage).toBe(false); + expect(result.previousCursor).toBeUndefined(); + }); + + it('should map SDK 1-based pageNumber to API 0-based on the first paginated call', async () => { + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse()); + + await governanceService.getPolicyTraces(startTime, { pageSize: 5 }); + + const [, body] = mockApiClient.post.mock.calls[0]; + expect(body.pageNumber).toBe(0); + expect(body.pageSize).toBe(5); + }); + + it('should subtract 1 from jumpToPage before sending to API', async () => { + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse()); + + await governanceService.getPolicyTraces(startTime, { jumpToPage: 3, pageSize: 5 }); + + const [, body] = mockApiClient.post.mock.calls[0]; + expect(body.pageNumber).toBe(2); + expect(body.pageSize).toBe(5); + }); + + it('should set hasNextPage=true when page is exactly full', async () => { + const pageSize = 2; + const items = Array.from({ length: pageSize }, (_, i) => + createMockRawGovernancePolicyTrace({ traceId: `trace-${i}` }), + ); + mockApiClient.post.mockResolvedValue(createMockRawGovernancePolicyTracesResponse(items)); + + const result = await governanceService.getPolicyTraces(startTime, { + pageSize, + }) as PaginatedResponse; + + expect(result.hasNextPage).toBe(true); + expect(result.nextCursor).toBeDefined(); + }); + + it('should set hasNextPage=false when page is partial', async () => { + const pageSize = 5; + mockApiClient.post.mockResolvedValue( + createMockRawGovernancePolicyTracesResponse([createMockRawGovernancePolicyTrace()]), + ); + + const result = await governanceService.getPolicyTraces(startTime, { + pageSize, + }) as PaginatedResponse; + + expect(result.hasNextPage).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); + + it('should round-trip pageNumber via cursor (next page → page 2 → API page 1)', async () => { + const pageSize = 2; + const fullPage = Array.from({ length: pageSize }, (_, i) => + createMockRawGovernancePolicyTrace({ traceId: `trace-${i}` }), + ); + mockApiClient.post.mockResolvedValueOnce(createMockRawGovernancePolicyTracesResponse(fullPage)); + + const page1 = await governanceService.getPolicyTraces(startTime, { pageSize }) as PaginatedResponse; + expect(page1.nextCursor).toBeDefined(); + expect(page1.hasNextPage).toBe(true); + + mockApiClient.post.mockResolvedValueOnce( + createMockRawGovernancePolicyTracesResponse([createMockRawGovernancePolicyTrace()]), + ); + + const page2 = await governanceService.getPolicyTraces(startTime, { + cursor: page1.nextCursor, + }) as PaginatedResponse; + + const [, body] = mockApiClient.post.mock.calls[1]; + expect(body.pageNumber).toBe(1); + expect(body.pageSize).toBe(pageSize); + expect(page2.currentPage).toBe(2); + }); + }); + + describe('getPolicyTraces - validation', () => { + it('should throw ValidationError when startTime is undefined', async () => { + const callWithoutStartTime = governanceService.getPolicyTraces.bind( + governanceService, + ) as (startTime?: Date) => Promise; + await expect(callWithoutStartTime()).rejects.toThrow('startTime is required'); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('should throw error when pageSize is zero', async () => { + await expect( + governanceService.getPolicyTraces(startTime, { pageSize: 0 } as GovernancePolicyTraceGetAllOptions), + ).rejects.toThrow('pageSize must be a positive number'); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('should throw error when jumpToPage is zero', async () => { + await expect( + governanceService.getPolicyTraces(startTime, { jumpToPage: 0 } as GovernancePolicyTraceGetAllOptions), + ).rejects.toThrow('jumpToPage must be a positive number'); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('should throw error when cursor is malformed', async () => { + await expect( + governanceService.getPolicyTraces(startTime, { + cursor: { value: 'not-a-valid-base64-json' }, + } as GovernancePolicyTraceGetAllOptions), + ).rejects.toThrow(); + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + }); + + describe('getPolicyTraces - error propagation', () => { + it('should propagate API errors', async () => { + mockApiClient.post.mockRejectedValue(new Error(GOVERNANCE_TEST_CONSTANTS.ERROR_GOVERNANCE_REQUEST_FAILED)); + + await expect(governanceService.getPolicyTraces(startTime)).rejects.toThrow( + GOVERNANCE_TEST_CONSTANTS.ERROR_GOVERNANCE_REQUEST_FAILED, + ); + }); + }); +}); diff --git a/tests/utils/constants/governance.ts b/tests/utils/constants/governance.ts new file mode 100644 index 000000000..814565047 --- /dev/null +++ b/tests/utils/constants/governance.ts @@ -0,0 +1,27 @@ +/** + * Governance test constants + */ + +export const GOVERNANCE_TEST_CONSTANTS = { + TENANT_ID: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + TRACE_ID: '11111111-aaaa-bbbb-cccc-222222222222', + TRACE_ID_2: '33333333-aaaa-bbbb-cccc-444444444444', + POLICY_ID: 'policy-active-001', + POLICY_NAME: 'Active External Storage Block', + POLICY_STATUS_ACTIVE: 'Active', + POLICY_STATUS_SIMULATED: 'Simulated', + ACTOR_PROCESS_ID: 'process-abc-001', + ACTOR_PROCESS_TYPE: 'CodedAgent', + ACTOR_IDENTITY_ID: 'identity-zzz-001', + RESOURCE_ID: 'resource-yyy-001', + RESOURCE_TYPE: 'StorageBucket', + FOLDER_KEY: '07107668-6576-455d-9046-cf13c14f6414', + PROCESS_KEY: 'maestro-process-key-001', + JOB_KEY: 'job-key-001', + START_TIME_ISO: '2024-01-01T00:00:00.000Z', + END_TIME_ISO: '2024-12-31T23:59:59.000Z', + STARTED_TIME_API: '2024-09-12T14:33:21Z', + EVALUATION_RESULT_DENY: 'Deny', + EVALUATION_DETAILS: '{"matchedRule":"deny-external-storage"}', + ERROR_GOVERNANCE_REQUEST_FAILED: 'Governance request failed', +} as const; diff --git a/tests/utils/constants/index.ts b/tests/utils/constants/index.ts index ce32a203e..80019a4ff 100644 --- a/tests/utils/constants/index.ts +++ b/tests/utils/constants/index.ts @@ -16,3 +16,4 @@ export * from './conversational-agent'; export * from './attachments'; export * from './feedback'; export * from './traces'; +export * from './governance'; diff --git a/tests/utils/mocks/governance.ts b/tests/utils/mocks/governance.ts new file mode 100644 index 000000000..4d4e38667 --- /dev/null +++ b/tests/utils/mocks/governance.ts @@ -0,0 +1,38 @@ +import { GOVERNANCE_TEST_CONSTANTS } from '../constants/governance'; +import type { RawGovernancePolicyTraceItem } from '../../../src/models/governance/governance.internal-types'; + +/** + * Creates a raw policy evaluation trace item (camelCase) matching the live API response shape. + */ +export const createMockRawGovernancePolicyTrace = ( + overrides: Partial = {}, +): RawGovernancePolicyTraceItem => ({ + tenantId: GOVERNANCE_TEST_CONSTANTS.TENANT_ID, + startTime: GOVERNANCE_TEST_CONSTANTS.STARTED_TIME_API, + finalEnforcement: 'Deny', + policyId: GOVERNANCE_TEST_CONSTANTS.POLICY_ID, + policyEnforcement: 'Deny', + policyEvaluationResult: GOVERNANCE_TEST_CONSTANTS.EVALUATION_RESULT_DENY, + policyName: GOVERNANCE_TEST_CONSTANTS.POLICY_NAME, + policyStatus: GOVERNANCE_TEST_CONSTANTS.POLICY_STATUS_ACTIVE, + policyEvaluationDetails: GOVERNANCE_TEST_CONSTANTS.EVALUATION_DETAILS, + actorProcessId: GOVERNANCE_TEST_CONSTANTS.ACTOR_PROCESS_ID, + actorProcessType: GOVERNANCE_TEST_CONSTANTS.ACTOR_PROCESS_TYPE, + actorIdentityId: GOVERNANCE_TEST_CONSTANTS.ACTOR_IDENTITY_ID, + resourceId: GOVERNANCE_TEST_CONSTANTS.RESOURCE_ID, + resourceType: GOVERNANCE_TEST_CONSTANTS.RESOURCE_TYPE, + folderKey: GOVERNANCE_TEST_CONSTANTS.FOLDER_KEY, + traceId: GOVERNANCE_TEST_CONSTANTS.TRACE_ID, + processKey: GOVERNANCE_TEST_CONSTANTS.PROCESS_KEY, + jobKey: GOVERNANCE_TEST_CONSTANTS.JOB_KEY, + ...overrides, +}); + +/** + * Creates a raw policy evaluation traces response envelope (camelCase). + */ +export const createMockRawGovernancePolicyTracesResponse = ( + items: RawGovernancePolicyTraceItem[] = [createMockRawGovernancePolicyTrace()], +): { items: RawGovernancePolicyTraceItem[] } => ({ + items, +}); diff --git a/tests/utils/mocks/index.ts b/tests/utils/mocks/index.ts index 63d1e4450..c8dd4e5b9 100644 --- a/tests/utils/mocks/index.ts +++ b/tests/utils/mocks/index.ts @@ -20,6 +20,7 @@ export * from './conversational-agent'; export * from './feedback'; export * from './attachments'; export * from './traces'; +export * from './governance'; // Re-export constants for convenience export * from '../constants'; \ No newline at end of file