diff --git a/src/tests/e2e/condition-sets.e2e.spec.ts b/src/tests/e2e/condition-sets.e2e.spec.ts new file mode 100644 index 0000000..8ec32fe --- /dev/null +++ b/src/tests/e2e/condition-sets.e2e.spec.ts @@ -0,0 +1,149 @@ +import pino from 'pino'; + +import { IPermitClient } from '../../index'; +import { createTestClient } from '../fixtures'; +import { waitForCheck } from '../helpers/wait-for'; + +let permit: IPermitClient; +let logger: pino.Logger; + +// Unique per-run suffix so concurrent/repeated runs never collide on the shared +// environment, and so the tolerant afterAll only ever touches what this spec made. +const rand = Math.random().toString(36).slice(2, 8); +const RESOURCE_KEY = `cs_e2e_doc_${rand}`; +const USERSET_KEY = `cs_e2e_userset_${rand}`; +const RESOURCESET_KEY = `cs_e2e_resourceset_${rand}`; +const MATCHING_USER_KEY = `cs_e2e_user_match_${rand}`; +const OTHER_USER_KEY = `cs_e2e_user_other_${rand}`; +// Built-in 'user' resource key in Permit. A userset condition that references +// user. requires the attribute to exist on this resource first. +const USER_RESOURCE_KEY = '__user'; +const USER_ATTR = `cs_e2e_clearance_${rand}`; +const ACTION = 'read'; +const PERMISSION = `${RESOURCE_KEY}:${ACTION}`; +const TENANT = 'default'; + +beforeAll(() => { + ({ permit, logger } = createTestClient()); +}); + +afterAll(async () => { + // Tolerant teardown in dependency order (rule -> condition sets -> users -> + // resource). Each delete swallows its own error so a partial run (the body + // threw before creating an entity) neither throws nor masks the original + // failure. The deletes cascade, then the assertions confirm nothing leaked. + await permit.api.conditionSetRules + .delete({ user_set: USERSET_KEY, permission: PERMISSION, resource_set: RESOURCESET_KEY }) + .catch(() => undefined); + await permit.api.conditionSets.delete(USERSET_KEY).catch(() => undefined); + await permit.api.conditionSets.delete(RESOURCESET_KEY).catch(() => undefined); + // Remove the attribute from the built-in user resource after the userset that + // referenced it is gone. The built-in '__user' resource itself is never deleted. + await permit.api.resourceAttributes.delete(USER_RESOURCE_KEY, USER_ATTR).catch(() => undefined); + await permit.api.users.delete(MATCHING_USER_KEY).catch(() => undefined); + await permit.api.users.delete(OTHER_USER_KEY).catch(() => undefined); + await permit.api.resources.delete(RESOURCE_KEY).catch(() => undefined); + + const sets = await permit.api.conditionSets.list(); + expect(sets.some((set) => set.key === USERSET_KEY)).toBe(false); + expect(sets.some((set) => set.key === RESOURCESET_KEY)).toBe(false); + + const resources = await permit.api.resources.list(); + expect(resources.some((resource) => resource.key === RESOURCE_KEY)).toBe(false); + + const users = (await permit.api.users.list()).data; + expect(users.some((user) => user.key === MATCHING_USER_KEY)).toBe(false); + expect(users.some((user) => user.key === OTHER_USER_KEY)).toBe(false); +}); + +it('ABAC condition-set permission check e2e test', async () => { + logger.info('creating an attributed resource for ABAC'); + const resource = await permit.api.resources.create({ + key: RESOURCE_KEY, + name: RESOURCE_KEY, + actions: { [ACTION]: {} }, + attributes: { + confidential: { + type: 'bool', + description: 'whether the document is confidential', + }, + }, + }); + expect(resource.key).toBe(RESOURCE_KEY); + expect((resource.actions ?? {})[ACTION]).not.toBe(undefined); + + logger.info('registering the user attribute referenced by the userset condition'); + const userAttribute = await permit.api.resourceAttributes.create(USER_RESOURCE_KEY, { + key: USER_ATTR, + type: 'string', + }); + expect(userAttribute.key).toBe(USER_ATTR); + + logger.info('creating the userset condition set (matches a user attribute)'); + const userSet = await permit.api.conditionSets.create({ + key: USERSET_KEY, + name: USERSET_KEY, + type: 'userset', + conditions: { + allOf: [{ [`user.${USER_ATTR}`]: { equals: 'top_secret' } }], + }, + }); + expect(userSet.key).toBe(USERSET_KEY); + expect(userSet.type).toBe('userset'); + + logger.info('creating the resourceset condition set (matches a resource attribute)'); + const resourceSet = await permit.api.conditionSets.create({ + key: RESOURCESET_KEY, + name: RESOURCESET_KEY, + type: 'resourceset', + resource_id: RESOURCE_KEY, + conditions: { + allOf: [{ 'resource.confidential': { equals: true } }], + }, + }); + expect(resourceSet.key).toBe(RESOURCESET_KEY); + expect(resourceSet.type).toBe('resourceset'); + + logger.info('linking the sets with a condition-set rule that grants the action'); + const rule = await permit.api.conditionSetRules.create({ + user_set: USERSET_KEY, + permission: PERMISSION, + resource_set: RESOURCESET_KEY, + }); + expect(rule.user_set).toBe(USERSET_KEY); + expect(rule.resource_set).toBe(RESOURCESET_KEY); + expect(rule.permission).toBe(PERMISSION); + + logger.info('syncing a user that matches the userset, and one that does not'); + const { user: matchingUser } = await permit.api.users.sync({ + key: MATCHING_USER_KEY, + attributes: { [USER_ATTR]: 'top_secret' }, + }); + expect(matchingUser.key).toBe(MATCHING_USER_KEY); + expect((matchingUser.attributes as Record)[USER_ATTR]).toBe('top_secret'); + + const { user: otherUser } = await permit.api.users.sync({ + key: OTHER_USER_KEY, + attributes: { [USER_ATTR]: 'public' }, + }); + expect(otherUser.key).toBe(OTHER_USER_KEY); + + const confidentialResource = { + type: RESOURCE_KEY, + tenant: TENANT, + attributes: { confidential: true }, + }; + + // Wait for the writes above to propagate from the control plane to the PDP. + // Condition sets compile to new policy (rego), which takes longer to take + // effect than plain role/fact propagation, so allow a wider budget. + await waitForCheck(() => permit.check(MATCHING_USER_KEY, ACTION, confidentialResource), true, { + timeoutMs: 180_000, + }); + + logger.info('positive ABAC check: matching user reads a confidential document'); + expect(await permit.check(MATCHING_USER_KEY, ACTION, confidentialResource)).toBe(true); + + logger.info('negative ABAC check: non-matching user is denied'); + expect(await permit.check(OTHER_USER_KEY, ACTION, confidentialResource)).toBe(false); +}); diff --git a/src/tests/helpers/mock-api.ts b/src/tests/helpers/mock-api.ts new file mode 100644 index 0000000..c42ec44 --- /dev/null +++ b/src/tests/helpers/mock-api.ts @@ -0,0 +1,320 @@ +import { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +import { Permit } from '../../index'; + +/** + * A single HTTP request captured by a {@link MockTransport} adapter. + * + * The generated openapi client bakes query parameters into the URL string and + * pre-serializes the request body to a JSON string. To keep assertions + * ergonomic, the capturing adapter normalizes both: + * - `params` is parsed from the URL query string (so the values are strings, + * e.g. `page: '1'`, never the numbers the caller passed). + * - `data` is parsed back from the JSON string into an object. + */ +export interface CapturedRequest { + /** Upper-cased HTTP method, e.g. `'GET'` / `'POST'` (axios lower-cases it internally). */ + method?: string; + /** The request URL. For REST this is absolute; for PDP/OPA it is relative to `baseURL`. */ + url?: string; + /** The axios `baseURL` (set for the PDP/OPA instances, undefined for REST). */ + baseURL?: string; + /** Query parameters parsed from the URL (values are strings) merged with any `config.params`. */ + params?: any; + /** Request body, parsed from JSON back into an object when possible. */ + data?: any; + /** The finalized request headers (an `AxiosHeaders` instance). */ + headers?: any; +} + +/** + * Captures the requests dispatched on one axios instance and lets a test queue + * the responses those requests resolve/reject with. + * + * Queued responses are consumed FIFO: each request shifts the next queued entry. + * When the queue is empty a request resolves with `200 {}` so simple + * "did the SDK dispatch the right request?" assertions need no setup. + */ +export interface MockTransport { + /** Every request captured so far, in dispatch order. */ + readonly requests: CapturedRequest[]; + /** The most recently captured request, or `undefined` if none yet. */ + readonly last: CapturedRequest | undefined; + /** Queue the next response. Defaults to status `200` with `{}` data. */ + resolveWith(data?: unknown, status?: number): void; + /** Queue a synthetic {@link AxiosError} so `handleApiError` maps it to `PermitApiError`. */ + rejectWith(status: number, data?: unknown): void; + /** Clear both captured requests and the queued responses. */ + reset(): void; +} + +/** Options for {@link createMockPermit}. All fields are optional. */ +export interface MockPermitOptions { + /** + * Route facts modules (users, tenants, role-assignments, ...) through the PDP + * base URL instead of the REST API. The facts modules still dispatch on the + * REST axios instance, so the `rest` transport captures them either way; only + * the captured `url`/`baseURL` changes. + */ + proxyFactsViaPdp?: boolean; + /** Organization key seeded into the SDK context. Defaults to `'org'`. */ + org?: string; + /** Project key seeded into the SDK context. Defaults to `'proj'`. */ + project?: string; + /** Environment key seeded into the SDK context. Defaults to `'env'`. */ + environment?: string; + /** Which context level to pre-seed (gates which modules can run). Defaults to `'environment'`. */ + contextLevel?: 'environment' | 'project' | 'organization'; + /** The API token passed to the SDK. Defaults to `'test-token'`. */ + token?: string; +} + +/** The result of {@link createMockPermit}. */ +export interface MockPermit { + /** A fully constructed {@link Permit} whose three axios instances are mocked. */ + permit: Permit; + /** Captures REST / control-plane calls (`permit.api.*`). */ + rest: MockTransport; + /** Captures PDP calls (`permit.check`, `permit.bulkCheck`, `permit.getUserPermissions`). */ + pdp: MockTransport; + /** Captures OPA calls (`permit.check(..., { useOpa: true })`). */ + opa: MockTransport; +} + +interface QueuedResponse { + kind: 'resolve' | 'reject'; + status: number; + data: unknown; +} + +/** + * Builds a synthetic {@link AxiosError} that mirrors a real HTTP error response. + * + * `handleApiError` checks `axios.isAxiosError(err)` and reads `err.response`, so + * the error must carry `isAxiosError`, a `response` (with `status`/`data`), the + * live request `config` and a `toJSON` method. The `data` defaults to `{}` so + * `handleApiError`'s `err.response?.data.message` access never throws. + * + * @param status - The HTTP status code to report. + * @param data - The response body (defaults to `{}`). + * @param config - The request config to attach (defaults to an empty config). + * @returns An `AxiosError` ready to be thrown from an adapter. + */ +export function synthAxiosError( + status: number, + data: unknown = {}, + config: InternalAxiosRequestConfig = {} as InternalAxiosRequestConfig, +): AxiosError { + const error = new Error(`Request failed with status code ${status}`) as AxiosError; + error.isAxiosError = true; + error.config = config; + error.toJSON = () => ({}); + error.response = { + status, + statusText: 'Error', + headers: {}, + config, + data, + }; + return error; +} + +class MockTransportImpl implements MockTransport { + public readonly requests: CapturedRequest[] = []; + private readonly queue: QueuedResponse[] = []; + + public get last(): CapturedRequest | undefined { + return this.requests[this.requests.length - 1]; + } + + public resolveWith(data: unknown = {}, status = 200): void { + this.queue.push({ kind: 'resolve', status, data }); + } + + public rejectWith(status: number, data: unknown = {}): void { + this.queue.push({ kind: 'reject', status, data }); + } + + public reset(): void { + this.requests.length = 0; + this.queue.length = 0; + } + + public capture(config: InternalAxiosRequestConfig): void { + this.requests.push(toCaptured(config)); + } + + public next(): QueuedResponse | undefined { + return this.queue.shift(); + } +} + +function parseBody(data: unknown): unknown { + if (typeof data !== 'string') { + return data; + } + try { + return JSON.parse(data); + } catch { + // Not JSON (e.g. a form/string body); return it verbatim. + return data; + } +} + +function extractParams(config: InternalAxiosRequestConfig): Record { + const params: Record = {}; + try { + const url = new URL(config.url ?? '', config.baseURL || 'http://mock.local'); + url.searchParams.forEach((value, key) => { + params[key] = value; + }); + } catch { + // URL not parseable; fall through to whatever config.params holds. + } + if (config.params && typeof config.params === 'object') { + Object.assign(params, config.params); + } + return params; +} + +function toCaptured(config: InternalAxiosRequestConfig): CapturedRequest { + return { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + params: extractParams(config), + data: parseBody(config.data), + headers: config.headers, + }; +} + +/** + * Installs a network-free capturing adapter on one axios instance and returns + * the {@link MockTransport} that controls it. + */ +function installAdapter(instance: AxiosInstance): MockTransport { + const transport = new MockTransportImpl(); + instance.defaults.adapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + transport.capture(config); + const queued = transport.next(); + if (queued && queued.kind === 'reject') { + throw synthAxiosError(queued.status, queued.data, config); + } + return { + data: queued?.data ?? {}, + status: queued?.status ?? 200, + statusText: 'OK', + headers: {}, + config, + }; + }; + return transport; +} + +function seedContext( + permit: Permit, + level: 'environment' | 'project' | 'organization', + org: string, + project: string, + environment: string, +): void { + const ctx = permit.config.apiContext; + if (level === 'organization') { + ctx._saveApiKeyAccessibleScope(org); + ctx.setOrganizationLevelContext(org); + return; + } + if (level === 'project') { + ctx._saveApiKeyAccessibleScope(org, project); + ctx.setProjectLevelContext(org, project); + return; + } + ctx._saveApiKeyAccessibleScope(org, project, environment); + ctx.setEnvironmentLevelContext(org, project, environment); +} + +/** + * Constructs a {@link Permit} SDK whose REST, PDP and OPA axios instances are all + * replaced with network-free capturing adapters, and pre-seeds the SDK context + * so API methods skip the `getApiKeyScope` HTTP call. + * + * Usage: + * ```ts + * const { permit, rest } = createMockPermit(); + * rest.resolveWith([{ key: 'doc' }]); // queue the next response + * await permit.api.resources.list(); + * expect(rest.last?.method).toBe('GET'); // assert the dispatched request + * expect(rest.last?.url).toContain('/resources'); + * ``` + * + * Notes for spec authors: + * - Responses are FIFO. Queue one `resolveWith`/`rejectWith` per request the SDK + * will make; an unqueued request resolves with `200 {}`. + * - `params` values are strings (parsed from the URL query), e.g. `page: '1'`. + * - `data` is the parsed request body, so assert with `toEqual(payload)`. + * - `rejectWith(status)` produces a `PermitApiError` whose `.response.status` + * equals `status` (the SDK maps AxiosErrors via `handleApiError`). + * - The SDK is constructed with retries left disabled (the default), so every + * request hits the adapter exactly once. + * + * @param opts - See {@link MockPermitOptions}. + * @returns The constructed permit plus the three transports. + */ +export function createMockPermit(opts: MockPermitOptions = {}): MockPermit { + const { + proxyFactsViaPdp = false, + org = 'org', + project = 'proj', + environment = 'env', + contextLevel = 'environment', + token = 'test-token', + } = opts; + + const permit = new Permit({ + token, + pdp: 'http://localhost:7766', + apiUrl: 'http://localhost:8000', + proxyFactsViaPdp, + }); + + const enforcer = ( + permit as unknown as { + enforcer: { client: AxiosInstance; opaClient: AxiosInstance }; + } + ).enforcer; + + const rest = installAdapter(permit.config.axiosInstance); + const pdp = installAdapter(enforcer.client); + const opa = installAdapter(enforcer.opaClient); + + seedContext(permit, contextLevel, org, project, environment); + + return { permit, rest, pdp, opa }; +} + +/** A pino-shaped logger whose methods are `vi.fn()` mocks, for util/logger specs. */ +export interface FakeLogger { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + child: () => FakeLogger; +} + +/** + * Returns a minimal pino-compatible logger backed by `vi.fn()` spies. + * + * `child()` returns a fresh {@link FakeLogger} so nested loggers are also + * inspectable. Relies on the global `vi` provided by Vitest (`globals: true`). + */ +export function fakeLogger(): FakeLogger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: () => fakeLogger(), + }; +} diff --git a/src/tests/unit/api/condition-set-rules.spec.ts b/src/tests/unit/api/condition-set-rules.spec.ts new file mode 100644 index 0000000..ad1f98f --- /dev/null +++ b/src/tests/unit/api/condition-set-rules.spec.ts @@ -0,0 +1,125 @@ +import { PermitApiError } from '../../../api/base'; +import { ConditionSetRuleCreate, ConditionSetRuleRemove } from '../../../api/condition-set-rules'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// set-rules URL is scoped under `/v2/facts/{proj}/{env}/set_rules`. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/facts/${PROJ}/${ENV}/set_rules`; + +describe('ConditionSetRulesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection mapping filters to wire params', async () => { + rest.resolveWith([]); + + await permit.api.conditionSetRules.list({ + userSetKey: 'us-employees', + permissionKey: 'document:read', + resourceSetKey: 'confidential-docs', + }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // userSetKey/permissionKey/resourceSetKey map to snake_case wire params. + expect(rest.last?.params).toMatchObject({ + user_set: 'us-employees', + permission: 'document:read', + resource_set: 'confidential-docs', + page: '1', + per_page: '100', + }); + }); + + it('forwards page and perPage alongside the filters', async () => { + rest.resolveWith([]); + + await permit.api.conditionSetRules.list({ + userSetKey: 'us-employees', + permissionKey: 'document:read', + resourceSetKey: 'confidential-docs', + page: 2, + perPage: 10, + }); + + expect(rest.last?.params).toMatchObject({ page: '2', per_page: '10' }); + }); + }); + + describe('create', () => { + const rule: ConditionSetRuleCreate = { + user_set: 'us-employees', + permission: 'document:read', + resource_set: 'confidential-docs', + }; + + it('POSTs the rule body to the collection', async () => { + // assignSetPermissions returns an array; the SDK unwraps `.data[0]`. + rest.resolveWith([{ ...rule, id: 'rule-1' }]); + + await permit.api.conditionSetRules.create(rule); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(rule); + }); + }); + + describe('delete', () => { + const rule: ConditionSetRuleRemove = { + user_set: 'us-employees', + permission: 'document:read', + resource_set: 'confidential-docs', + }; + + it('DELETEs the collection with the rule as the request body', async () => { + rest.resolveWith({}); + + await permit.api.conditionSetRules.delete(rule); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(rule); + }); + }); + + describe('error mapping', () => { + it('maps a 404 on list to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.conditionSetRules + .list({ + userSetKey: 'us-employees', + permissionKey: 'document:read', + resourceSetKey: 'confidential-docs', + }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.conditionSetRules + .create({ + user_set: 'us-employees', + permission: 'document:read', + resource_set: 'confidential-docs', + }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/condition-sets.spec.ts b/src/tests/unit/api/condition-sets.spec.ts new file mode 100644 index 0000000..f66ff34 --- /dev/null +++ b/src/tests/unit/api/condition-sets.spec.ts @@ -0,0 +1,154 @@ +import { PermitApiError } from '../../../api/base'; +import { ConditionSetCreate, ConditionSetUpdate } from '../../../api/condition-sets'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// condition-sets URL is scoped under `/v2/schema/{proj}/{env}/condition_sets`. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/condition_sets`; + +describe('ConditionSetsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.conditionSets.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // page/per_page are serialized into the URL query string (values are strings). + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.conditionSets.list({ page: 3, perPage: 25 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ page: '3', per_page: '25' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single condition set with the key in the path', async () => { + rest.resolveWith({ key: 'us-employees' }); + + await permit.api.conditionSets.get('us-employees'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/us-employees`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'us-employees' }); + + await permit.api.conditionSets.getByKey('us-employees'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/us-employees`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'set-1' }); + + await permit.api.conditionSets.getById('set-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/set-1`); + }); + }); + + describe('create', () => { + it('POSTs a userset body to the collection', async () => { + const payload: ConditionSetCreate = { + key: 'us-employees', + name: 'US based employees', + type: 'userset', + conditions: { allOf: [{ 'user.location': { equals: 'US' } }] }, + }; + rest.resolveWith({ ...payload, id: 'set-1' }); + + await permit.api.conditionSets.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + + it('POSTs a resourceset body to the collection', async () => { + const payload: ConditionSetCreate = { + key: 'confidential-docs', + name: 'Confidential documents', + type: 'resourceset', + resource_id: 'document', + conditions: { allOf: [{ 'resource.classification': { equals: 'secret' } }] }, + }; + rest.resolveWith({ ...payload, id: 'set-2' }); + + await permit.api.conditionSets.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the condition set body to the keyed path', async () => { + const body: ConditionSetUpdate = { + name: 'Renamed set', + conditions: { allOf: [{ 'user.location': { equals: 'EU' } }] }, + }; + rest.resolveWith({ key: 'us-employees', name: 'Renamed set' }); + + await permit.api.conditionSets.update('us-employees', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/us-employees`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.conditionSets.delete('us-employees'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/us-employees`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.conditionSets.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.conditionSets + .create({ key: 'us-employees', name: 'US based employees', type: 'userset' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/deprecated.spec.ts b/src/tests/unit/api/deprecated.spec.ts new file mode 100644 index 0000000..1e72f13 --- /dev/null +++ b/src/tests/unit/api/deprecated.spec.ts @@ -0,0 +1,142 @@ +import { PermitApiError } from '../../../api/base'; +import { Permit } from '../../../index'; +import { + ConditionSetType, + ResourceCreate, + RoleAssignmentCreate, + TenantUpdate, + UserCreate, +} from '../../../openapi'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with org='org', project='proj', +// environment='env'. Facts modules scope under /v2/facts/{proj}/{env}, schema +// modules under /v2/schema/{proj}/{env}. +const FACTS = '/v2/facts/proj/env'; +const SCHEMA = '/v2/schema/proj/env'; + +// These deprecated wrappers delegate to the same openapi clients as the modern +// `permit.api..()` surface. The tests below verify the +// delegation (method + path + body/params) for a representative slice across +// read/create/update/delete/assign rather than every one of the ~28 wrappers. +describe('DeprecatedApiClient (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('read', () => { + it('listUsers GETs the users collection and unwraps the paginated data', async () => { + rest.resolveWith({ data: [{ key: 'u1' }], total_count: 1, page_count: 1 }); + + const users = await permit.api.listUsers(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${FACTS}/users`); + // listUsers returns response.data.data (the inner array), not the envelope. + expect(users).toEqual([{ key: 'u1' }]); + }); + + it('getUser GETs the keyed user path and returns the body', async () => { + rest.resolveWith({ key: 'u1' }); + + const user = await permit.api.getUser('u1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${FACTS}/users/u1`); + expect(user).toEqual({ key: 'u1' }); + }); + + it('listConditionSets GETs condition_sets with type/page/per_page as wire params', async () => { + rest.resolveWith([{ key: 'cs1' }]); + + await permit.api.listConditionSets(ConditionSetType.Userset, 2, 10); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${SCHEMA}/condition_sets`); + expect(rest.last?.params).toMatchObject({ type: 'userset', page: '2', per_page: '10' }); + }); + }); + + describe('create', () => { + it('createUser POSTs the user body to the users collection', async () => { + const payload: UserCreate = { key: 'u1', email: 'u1@example.com' }; + rest.resolveWith({ ...payload, id: 'user-id-1' }); + + await permit.api.createUser(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(`${FACTS}/users`); + expect(rest.last?.data).toEqual(payload); + }); + + it('createResource POSTs the resource body to the schema resources collection', async () => { + const payload: ResourceCreate = { + key: 'doc', + name: 'Document', + actions: { read: {} }, + }; + rest.resolveWith({ ...payload, id: 'res-1' }); + + await permit.api.createResource(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(`${SCHEMA}/resources`); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('updateTenant PATCHes the tenant body to the keyed path', async () => { + const body: TenantUpdate = { name: 'Renamed Tenant' }; + rest.resolveWith({ key: 't1', name: 'Renamed Tenant' }); + + await permit.api.updateTenant('t1', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${FACTS}/tenants/t1`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('assign', () => { + it('assignRole POSTs the assignment body to the role_assignments collection', async () => { + const body: RoleAssignmentCreate = { role: 'admin', tenant: 't1', user: 'u1' }; + rest.resolveWith({ ...body, id: 'ra-1' }); + + await permit.api.assignRole(body); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(`${FACTS}/role_assignments`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('deleteUser DELETEs the keyed path and returns the raw AxiosResponse', async () => { + rest.resolveWith(undefined, 204); + + const response = await permit.api.deleteUser('u1'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${FACTS}/users/u1`); + // delete-style wrappers return the whole AxiosResponse, not the unwrapped body. + expect(response.status).toBe(204); + expect(response.data).toEqual({}); + }); + }); + + describe('error propagation', () => { + it('re-throws the raw AxiosError (deprecated wrappers do not map to PermitApiError)', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.getUser('missing').catch((err) => err); + + expect(error.isAxiosError).toBe(true); + expect(error).not.toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + }); +}); diff --git a/src/tests/unit/api/elements.spec.ts b/src/tests/unit/api/elements.spec.ts new file mode 100644 index 0000000..4ab980d --- /dev/null +++ b/src/tests/unit/api/elements.spec.ts @@ -0,0 +1,73 @@ +import { PermitApiError } from '../../../api/base'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// elementsLoginAs has no scope segment: it posts to a fixed auth endpoint and +// (unlike the schema/facts modules) is not gated on the SDK api context. +const LOGIN_PATH = '/v2/auth/elements_login_as'; + +describe('ElementsClient (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('loginAs', () => { + it('POSTs the snake_cased login body to the elements auth endpoint', async () => { + rest.resolveWith({ redirect_url: 'https://app.permit.io/embed' }); + + await permit.elements.loginAs({ userId: 'user-1', tenantId: 'tenant-1' }); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(LOGIN_PATH); + // { userId, tenantId } is mapped onto the wire shape { user_id, tenant_id }. + expect(rest.last?.data).toEqual({ user_id: 'user-1', tenant_id: 'tenant-1' }); + }); + + it('wraps the response data with a content.url derived from redirect_url', async () => { + rest.resolveWith({ redirect_url: 'https://app.permit.io/embed', token: 'tok-123' }); + + const result = await permit.elements.loginAs({ userId: 'user-1', tenantId: 'tenant-1' }); + + // The raw response fields are spread through unchanged... + expect(result.token).toBe('tok-123'); + expect(result.redirect_url).toBe('https://app.permit.io/embed'); + // ...and `content.url` mirrors the redirect_url so callers can embed it. + expect(result.content).toEqual({ url: 'https://app.permit.io/embed' }); + }); + + it('dispatches without seeding any environment scope into the path', async () => { + rest.resolveWith({ redirect_url: 'https://app.permit.io/embed' }); + + await permit.elements.loginAs({ userId: 'user-1', tenantId: 'tenant-1' }); + + expect(rest.requests).toHaveLength(1); + expect(rest.last?.url).not.toContain('/v2/schema/'); + expect(rest.last?.url).not.toContain('/v2/facts/'); + }); + + it('maps a 403 forbidden response to PermitApiError', async () => { + rest.rejectWith(403, { message: 'Forbidden access' }); + + const error = await permit.elements + .loginAs({ userId: 'user-1', tenantId: 'tenant-1' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(403); + }); + + it('maps a 404 (user/tenant not found) to PermitApiError', async () => { + rest.rejectWith(404, { message: 'User not found' }); + + const error = await permit.elements + .loginAs({ userId: 'missing', tenantId: 'tenant-1' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + }); +}); diff --git a/src/tests/unit/api/environments.spec.ts b/src/tests/unit/api/environments.spec.ts new file mode 100644 index 0000000..a613622 --- /dev/null +++ b/src/tests/unit/api/environments.spec.ts @@ -0,0 +1,174 @@ +import { PermitApiError } from '../../../api/base'; +import { EnvironmentCopy, EnvironmentCreate, EnvironmentUpdate } from '../../../api/environments'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// Environments live under a project: their paths are +// `/v2/projects/{projectKey}/envs[/{environmentKey}]`. Every method requires an +// organization context, and the project/environment keys are passed as explicit +// arguments (not read from the seeded scope), so the mock is seeded at the +// organization level. +const PROJECT = 'proj-a'; +const ENV = 'env-a'; +const COLLECTION = `/v2/projects/${PROJECT}/envs`; +const RESOURCE = `${COLLECTION}/${ENV}`; + +describe('EnvironmentsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit({ contextLevel: 'organization' })); + }); + + describe('list', () => { + it('GETs the project-scoped collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.environments.list({ projectKey: PROJECT }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.environments.list({ projectKey: PROJECT, page: 2, perPage: 10 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ page: '2', per_page: '10' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single environment with both keys in the path', async () => { + rest.resolveWith({ key: ENV }); + + await permit.api.environments.get(PROJECT, ENV); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(RESOURCE); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: ENV }); + + await permit.api.environments.getByKey(PROJECT, ENV); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(RESOURCE); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: ENV }); + + await permit.api.environments.getById(PROJECT, ENV); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(RESOURCE); + }); + }); + + describe('getStats', () => { + it('GETs the stats sub-path', async () => { + rest.resolveWith({}); + + await permit.api.environments.getStats(PROJECT, ENV); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${RESOURCE}/stats`); + }); + }); + + describe('getApiKey', () => { + it('GETs the api-key path keyed by project and environment', async () => { + rest.resolveWith({ secret: 'permit_key' }); + + await permit.api.environments.getApiKey(PROJECT, ENV); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`/v2/api-key/${PROJECT}/${ENV}`); + }); + }); + + describe('create', () => { + const payload: EnvironmentCreate = { + key: ENV, + name: 'Environment A', + }; + + it('POSTs the environment body to the project collection', async () => { + rest.resolveWith({ ...payload, id: 'env-id-1' }); + + await permit.api.environments.create(PROJECT, payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the environment body to the keyed path', async () => { + const body: EnvironmentUpdate = { name: 'Renamed' }; + rest.resolveWith({ key: ENV, name: 'Renamed' }); + + await permit.api.environments.update(PROJECT, ENV, body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(RESOURCE); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('copy', () => { + it('POSTs the copy params to the copy sub-path', async () => { + const copyParams: EnvironmentCopy = { + target_env: { existing: 'env-b' }, + conflict_strategy: 'overwrite', + }; + rest.resolveWith({ key: 'env-b' }); + + await permit.api.environments.copy(PROJECT, ENV, copyParams); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(`${RESOURCE}/copy`); + expect(rest.last?.data).toEqual(copyParams); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.environments.delete(PROJECT, ENV); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(RESOURCE); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.environments.get(PROJECT, 'missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.environments + .create(PROJECT, { key: ENV, name: 'Environment A' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/projects.spec.ts b/src/tests/unit/api/projects.spec.ts new file mode 100644 index 0000000..bc4dc40 --- /dev/null +++ b/src/tests/unit/api/projects.spec.ts @@ -0,0 +1,132 @@ +import { PermitApiError } from '../../../api/base'; +import { ProjectCreate, ProjectUpdate } from '../../../api/projects'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// Projects are an organization-scoped resource: their paths carry no +// `{proj}/{env}` schema segment, just `/v2/projects[/{key}]`. The mock must be +// seeded with an organization-level context or every method rejects before it +// can dispatch. +const COLLECTION = '/v2/projects'; + +describe('ProjectsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit({ contextLevel: 'organization' })); + }); + + describe('list', () => { + it('GETs the projects collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.projects.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.projects.list({ page: 3, perPage: 25 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ page: '3', per_page: '25' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single project with the key in the path', async () => { + rest.resolveWith({ key: 'proj-a' }); + + await permit.api.projects.get('proj-a'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/proj-a`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'proj-a' }); + + await permit.api.projects.getByKey('proj-a'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/proj-a`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'proj-1' }); + + await permit.api.projects.getById('proj-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/proj-1`); + }); + }); + + describe('create', () => { + const payload: ProjectCreate = { + key: 'proj-a', + name: 'Project A', + }; + + it('POSTs the project body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'proj-id-1' }); + + await permit.api.projects.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the project body to the keyed path', async () => { + const body: ProjectUpdate = { name: 'Renamed' }; + rest.resolveWith({ key: 'proj-a', name: 'Renamed' }); + + await permit.api.projects.update('proj-a', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/proj-a`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.projects.delete('proj-a'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/proj-a`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.projects.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.projects + .create({ key: 'proj-a', name: 'Project A' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/relationship-tuples.spec.ts b/src/tests/unit/api/relationship-tuples.spec.ts new file mode 100644 index 0000000..ec99848 --- /dev/null +++ b/src/tests/unit/api/relationship-tuples.spec.ts @@ -0,0 +1,179 @@ +import { PermitApiError } from '../../../api/base'; +import { RelationshipTupleCreate, RelationshipTupleDelete } from '../../../api/relationship-tuples'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// Facts modules dispatch on the REST transport; the env-scoped default context +// places every tuple URL under `/v2/facts/{proj}/{env}/relationship_tuples`. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/facts/${PROJ}/${ENV}/relationship_tuples`; +const BULK = `${COLLECTION}/bulk`; + +describe('RelationshipTuplesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection with no params when none are given', async () => { + rest.resolveWith([]); + + await permit.api.relationshipTuples.list({}); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // The SDK does not default pagination here, so omitted filters are absent. + expect(rest.last?.params).not.toHaveProperty('page'); + expect(rest.last?.params).not.toHaveProperty('tenant'); + }); + + it('forwards filters and pagination as snake_case wire params', async () => { + rest.resolveWith([]); + + await permit.api.relationshipTuples.list({ + tenant: 'default', + subject: 'user:alice', + relation: 'parent', + object: 'folder:root', + objectType: 'folder', + subjectType: 'user', + page: 2, + perPage: 5, + }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ + tenant: 'default', + subject: 'user:alice', + relation: 'parent', + object: 'folder:root', + object_type: 'folder', + subject_type: 'user', + page: '2', + per_page: '5', + }); + }); + }); + + describe('create', () => { + const tuple: RelationshipTupleCreate = { + subject: 'user:alice', + relation: 'parent', + object: 'folder:root', + tenant: 'default', + }; + + it('POSTs the tuple body to the facts collection', async () => { + rest.resolveWith({ ...tuple, id: 'tuple-1' }); + + await permit.api.relationshipTuples.create(tuple); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.url).not.toContain('/bulk'); + expect(rest.last?.data).toEqual(tuple); + }); + }); + + describe('delete', () => { + const tuple: RelationshipTupleDelete = { + subject: 'user:alice', + relation: 'parent', + object: 'folder:root', + }; + + it('DELETEs with the tuple as the request body', async () => { + rest.resolveWith({}); + + await permit.api.relationshipTuples.delete(tuple); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.url).not.toContain('/bulk'); + expect(rest.last?.data).toEqual(tuple); + }); + }); + + describe('bulkRelationshipTuples', () => { + it('POSTs the tuples wrapped under operations to the bulk path', async () => { + const tuples: RelationshipTupleCreate[] = [ + { subject: 'user:alice', relation: 'parent', object: 'folder:root', tenant: 'default' }, + { subject: 'user:bob', relation: 'member', object: 'folder:root', tenant: 'default' }, + ]; + rest.resolveWith({}); + + await permit.api.relationshipTuples.bulkRelationshipTuples(tuples); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(BULK); + expect(rest.last?.data).toEqual({ operations: tuples }); + }); + }); + + describe('bulkUnRelationshipTuples', () => { + it('DELETEs the tuples wrapped under idents to the bulk path', async () => { + const tuples: RelationshipTupleDelete[] = [ + { subject: 'user:alice', relation: 'parent', object: 'folder:root' }, + { subject: 'user:bob', relation: 'member', object: 'folder:root' }, + ]; + rest.resolveWith({}); + + await permit.api.relationshipTuples.bulkUnRelationshipTuples(tuples); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(BULK); + expect(rest.last?.data).toEqual({ idents: tuples }); + }); + }); + + describe('waitForSync', () => { + it('returns the same instance and ignores the call when proxyFactsViaPdp is off', () => { + const api = permit.api.relationshipTuples; + + expect(api.waitForSync(10)).toBe(api); + }); + + it('returns a clone that sends X-Wait-Timeout when proxyFactsViaPdp is on', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + const api = proxied.permit.api.relationshipTuples; + + const synced = api.waitForSync(30); + expect(synced).not.toBe(api); + + proxied.rest.resolveWith({}); + await synced.create({ subject: 'user:alice', relation: 'parent', object: 'folder:root' }); + + expect(proxied.rest.last?.method).toBe('POST'); + // proxyFactsViaPdp routes facts requests at the PDP host. + expect(proxied.rest.last?.url).toContain('http://localhost:7766'); + expect(proxied.rest.last?.headers?.['X-Wait-Timeout']).toBe('30'); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.relationshipTuples.list({}).catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.relationshipTuples + .create({ subject: 'user:alice', relation: 'parent', object: 'folder:root' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resource-action-groups.spec.ts b/src/tests/unit/api/resource-action-groups.spec.ts new file mode 100644 index 0000000..b53ff57 --- /dev/null +++ b/src/tests/unit/api/resource-action-groups.spec.ts @@ -0,0 +1,137 @@ +import { PermitApiError } from '../../../api/base'; +import { ResourceActionGroupCreate } from '../../../api/resource-action-groups'; +import { Permit } from '../../../index'; +import { ResourceActionGroupUpdate } from '../../../openapi'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// action-group URL is nested under +// `/v2/schema/{proj}/{env}/resources/{resourceKey}/action_groups`. +const PROJ = 'proj'; +const ENV = 'env'; +const RESOURCE = 'document'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/resources/${RESOURCE}/action_groups`; + +describe('ResourceActionGroupsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the resource-scoped action_groups collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.actionGroups.list({ resourceKey: RESOURCE }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.actionGroups.list({ resourceKey: RESOURCE, page: 2, perPage: 10 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '2', per_page: '10' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single group with the resource key and group key in the path', async () => { + rest.resolveWith({ key: 'editing' }); + + await permit.api.actionGroups.get(RESOURCE, 'editing'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/editing`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'editing' }); + + await permit.api.actionGroups.getByKey(RESOURCE, 'editing'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/editing`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'group-1' }); + + await permit.api.actionGroups.getById(RESOURCE, 'group-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/group-1`); + }); + }); + + describe('create', () => { + const payload: ResourceActionGroupCreate = { + key: 'editing', + name: 'Editing', + actions: ['read', 'write'], + }; + + it('POSTs the group body to the resource-scoped collection', async () => { + rest.resolveWith({ ...payload, id: 'group-1' }); + + await permit.api.actionGroups.create(RESOURCE, payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the group body to the keyed path', async () => { + const body: ResourceActionGroupUpdate = { name: 'Editing renamed' }; + rest.resolveWith({ key: 'editing', name: 'Editing renamed' }); + + await permit.api.actionGroups.update(RESOURCE, 'editing', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/editing`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.actionGroups.delete(RESOURCE, 'editing'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/editing`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.actionGroups.get(RESOURCE, 'missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.actionGroups + .create(RESOURCE, { key: 'editing', name: 'Editing' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resource-actions.spec.ts b/src/tests/unit/api/resource-actions.spec.ts new file mode 100644 index 0000000..2b4a347 --- /dev/null +++ b/src/tests/unit/api/resource-actions.spec.ts @@ -0,0 +1,131 @@ +import { PermitApiError } from '../../../api/base'; +import { ResourceActionCreate, ResourceActionUpdate } from '../../../api/resource-actions'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// actions URL is nested under `/v2/schema/{proj}/{env}/resources/{resourceKey}/actions`. +const PROJ = 'proj'; +const ENV = 'env'; +const RESOURCE = 'document'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/resources/${RESOURCE}/actions`; + +describe('ResourceActionsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the resource-scoped actions collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.resourceActions.list({ resourceKey: RESOURCE }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.resourceActions.list({ resourceKey: RESOURCE, page: 3, perPage: 7 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '3', per_page: '7' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single action with the resource key and action key in the path', async () => { + rest.resolveWith({ key: 'read' }); + + await permit.api.resourceActions.get(RESOURCE, 'read'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/read`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'read' }); + + await permit.api.resourceActions.getByKey(RESOURCE, 'read'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/read`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'action-1' }); + + await permit.api.resourceActions.getById(RESOURCE, 'action-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/action-1`); + }); + }); + + describe('create', () => { + const payload: ResourceActionCreate = { key: 'read', name: 'Read' }; + + it('POSTs the action body to the resource-scoped collection', async () => { + rest.resolveWith({ ...payload, id: 'action-1' }); + + await permit.api.resourceActions.create(RESOURCE, payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the action body to the keyed path', async () => { + const body: ResourceActionUpdate = { name: 'Read renamed' }; + rest.resolveWith({ key: 'read', name: 'Read renamed' }); + + await permit.api.resourceActions.update(RESOURCE, 'read', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/read`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.resourceActions.delete(RESOURCE, 'read'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/read`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceActions.get(RESOURCE, 'missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resourceActions + .create(RESOURCE, { key: 'read', name: 'Read' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resource-attributes.spec.ts b/src/tests/unit/api/resource-attributes.spec.ts new file mode 100644 index 0000000..1456b67 --- /dev/null +++ b/src/tests/unit/api/resource-attributes.spec.ts @@ -0,0 +1,134 @@ +import { PermitApiError } from '../../../api/base'; +import { ResourceAttributeCreate, ResourceAttributeUpdate } from '../../../api/resource-attributes'; +import { Permit } from '../../../index'; +import { AttributeType } from '../../../openapi'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// attributes URL is nested under `/v2/schema/{proj}/{env}/resources/{resourceKey}/attributes`. +const PROJ = 'proj'; +const ENV = 'env'; +const RESOURCE = 'document'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/resources/${RESOURCE}/attributes`; + +describe('ResourceAttributesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the resource-scoped attributes collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.resourceAttributes.list({ resourceKey: RESOURCE }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.resourceAttributes.list({ resourceKey: RESOURCE, page: 4, perPage: 25 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '4', per_page: '25' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single attribute with the resource key and attribute key in the path', async () => { + rest.resolveWith({ key: 'owner' }); + + await permit.api.resourceAttributes.get(RESOURCE, 'owner'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/owner`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'owner' }); + + await permit.api.resourceAttributes.getByKey(RESOURCE, 'owner'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/owner`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'attr-1' }); + + await permit.api.resourceAttributes.getById(RESOURCE, 'attr-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/attr-1`); + }); + }); + + describe('create', () => { + const payload: ResourceAttributeCreate = { key: 'owner', type: AttributeType.String }; + + it('POSTs the attribute body to the resource-scoped collection', async () => { + rest.resolveWith({ ...payload, id: 'attr-1' }); + + await permit.api.resourceAttributes.create(RESOURCE, payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the attribute body to the keyed path', async () => { + const body: ResourceAttributeUpdate = { type: AttributeType.Number }; + rest.resolveWith({ key: 'owner', type: 'number' }); + + await permit.api.resourceAttributes.update(RESOURCE, 'owner', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/owner`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.resourceAttributes.delete(RESOURCE, 'owner'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/owner`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceAttributes + .get(RESOURCE, 'missing') + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resourceAttributes + .create(RESOURCE, { key: 'owner', type: AttributeType.String }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resource-instances.spec.ts b/src/tests/unit/api/resource-instances.spec.ts new file mode 100644 index 0000000..f6cff8e --- /dev/null +++ b/src/tests/unit/api/resource-instances.spec.ts @@ -0,0 +1,188 @@ +import { PermitApiError } from '../../../api/base'; +import { ResourceInstanceCreate, ResourceInstanceUpdate } from '../../../api/resource-instances'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// resource-instance URL is scoped under `/v2/facts/{proj}/{env}/resource_instances`. +// Resource instances is a facts module, hence the `/v2/facts` prefix. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/facts/${PROJ}/${ENV}/resource_instances`; + +describe('ResourceInstancesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection without injecting pagination defaults', async () => { + rest.resolveWith([]); + + await permit.api.resourceInstances.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // list() forwards only the params it is given, so page/per_page and the + // tenant/resource filters are absent when the caller omits them. + expect(rest.last?.params).not.toHaveProperty('page'); + expect(rest.last?.params).not.toHaveProperty('tenant'); + expect(rest.last?.params).not.toHaveProperty('resource'); + }); + + it('forwards the tenant and resource filters plus pagination as wire params', async () => { + rest.resolveWith([]); + + await permit.api.resourceInstances.list({ + tenant: 't1', + resource: 'document', + page: 2, + perPage: 5, + }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ + tenant: 't1', + resource: 'document', + page: '2', + per_page: '5', + }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single resource instance with the key in the path', async () => { + rest.resolveWith({ key: 'inst-1' }); + + await permit.api.resourceInstances.get('inst-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/inst-1`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'inst-1' }); + + await permit.api.resourceInstances.getByKey('inst-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/inst-1`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'instance-id' }); + + await permit.api.resourceInstances.getById('instance-id'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/instance-id`); + }); + }); + + describe('create', () => { + const payload: ResourceInstanceCreate = { + key: 'inst-1', + resource: 'document', + tenant: 't1', + attributes: { private: true }, + }; + + it('POSTs the resource-instance body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'inst-id-1' }); + + await permit.api.resourceInstances.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the resource-instance body to the keyed path', async () => { + const body: ResourceInstanceUpdate = { attributes: { private: false } }; + rest.resolveWith({ key: 'inst-1' }); + + await permit.api.resourceInstances.update('inst-1', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/inst-1`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.resourceInstances.delete('inst-1'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/inst-1`); + }); + }); + + describe('waitForSync', () => { + it('returns the same instance and dispatches to the REST host when proxy is off', async () => { + const instances = permit.api.resourceInstances; + + expect(instances.waitForSync(10)).toBe(instances); + + rest.resolveWith([]); + await instances.list(); + expect(rest.last?.url).toContain('http://localhost:8000'); + }); + + it('returns a distinct clone that dispatches to the PDP host when proxy is on', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + const instances = proxied.permit.api.resourceInstances; + + const synced = instances.waitForSync(10); + expect(synced).not.toBe(instances); + + proxied.rest.resolveWith([]); + await synced.list(); + expect(proxied.rest.last?.url).toContain('http://localhost:7766/v2/facts'); + }); + }); + + describe('proxyFactsViaPdp', () => { + it('routes the request through the PDP host while still using the rest transport', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + proxied.rest.resolveWith([]); + + await proxied.permit.api.resourceInstances.list(); + + expect(proxied.rest.last?.method).toBe('GET'); + expect(proxied.rest.last?.url).toContain( + 'http://localhost:7766/v2/facts/proj/env/resource_instances', + ); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceInstances.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resourceInstances + .create({ key: 'inst-1', resource: 'document' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resource-relations.spec.ts b/src/tests/unit/api/resource-relations.spec.ts new file mode 100644 index 0000000..fe9e703 --- /dev/null +++ b/src/tests/unit/api/resource-relations.spec.ts @@ -0,0 +1,122 @@ +import { PermitApiError } from '../../../api/base'; +import { RelationCreate } from '../../../api/resource-relations'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// relations URL is scoped under the resource passed to each call. +const PROJ = 'proj'; +const ENV = 'env'; +const RESOURCE = 'folder'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/resources/${RESOURCE}/relations`; + +describe('ResourceRelationsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the resource-scoped relations collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.resourceRelations.list({ resourceKey: RESOURCE }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // The SDK defaults page/perPage, so they are always serialized as strings. + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.resourceRelations.list({ resourceKey: RESOURCE, page: 3, perPage: 25 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ page: '3', per_page: '25' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single relation with resource and relation keys in the path', async () => { + rest.resolveWith({ key: 'parent' }); + + await permit.api.resourceRelations.get(RESOURCE, 'parent'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/parent`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'parent' }); + + await permit.api.resourceRelations.getByKey(RESOURCE, 'parent'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/parent`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'rel-1' }); + + await permit.api.resourceRelations.getById(RESOURCE, 'rel-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/rel-1`); + }); + }); + + describe('create', () => { + const payload: RelationCreate = { + key: 'parent', + name: 'Parent', + subject_resource: 'account', + }; + + it('POSTs the relation body with the resource key in the path', async () => { + rest.resolveWith({ ...payload, id: 'rel-1' }); + + await permit.api.resourceRelations.create(RESOURCE, payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed relation path', async () => { + rest.resolveWith({}); + + await permit.api.resourceRelations.delete(RESOURCE, 'parent'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/parent`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceRelations.get(RESOURCE, 'missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resourceRelations + .create(RESOURCE, { key: 'parent', name: 'Parent', subject_resource: 'account' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resource-roles.spec.ts b/src/tests/unit/api/resource-roles.spec.ts new file mode 100644 index 0000000..db62392 --- /dev/null +++ b/src/tests/unit/api/resource-roles.spec.ts @@ -0,0 +1,274 @@ +import { PermitApiError } from '../../../api/base'; +import { ResourceRoleCreate, ResourceRoleUpdate } from '../../../api/resource-roles'; +import { Permit } from '../../../index'; +import { + DerivedRoleRuleCreate, + DerivedRoleRuleDelete, + PermitBackendSchemasSchemaDerivedRoleDerivedRoleSettings, +} from '../../../openapi'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// resource-role URL is scoped under +// `/v2/schema/{proj}/{env}/resources/{resourceKey}/roles`. +const PROJ = 'proj'; +const ENV = 'env'; +const RESOURCE = 'doc'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/resources/${RESOURCE}/roles`; + +describe('ResourceRolesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the resource-scoped collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.resourceRoles.list({ resourceKey: RESOURCE }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + }); + + it('forwards page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.resourceRoles.list({ resourceKey: RESOURCE, page: 2, perPage: 10 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '2', per_page: '10' }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single resource role with both keys in the path', async () => { + rest.resolveWith({ key: 'editor' }); + + await permit.api.resourceRoles.get(RESOURCE, 'editor'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/editor`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'editor' }); + + await permit.api.resourceRoles.getByKey(RESOURCE, 'editor'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/editor`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'role-1' }); + + await permit.api.resourceRoles.getById(RESOURCE, 'role-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/role-1`); + }); + }); + + describe('create', () => { + const payload: ResourceRoleCreate = { + key: 'editor', + name: 'Editor', + permissions: ['read', 'write'], + }; + + it('POSTs the role body to the resource-scoped collection', async () => { + rest.resolveWith({ ...payload, id: 'role-1' }); + + await permit.api.resourceRoles.create(RESOURCE, payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the role body to the keyed path', async () => { + const body: ResourceRoleUpdate = { name: 'Renamed' }; + rest.resolveWith({ key: 'editor', name: 'Renamed' }); + + await permit.api.resourceRoles.update(RESOURCE, 'editor', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/editor`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.resourceRoles.delete(RESOURCE, 'editor'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/editor`); + }); + }); + + describe('assignPermissions', () => { + it('POSTs the permissions body to the role permissions path', async () => { + const permissions = ['doc:read', 'doc:write']; + rest.resolveWith({ key: 'editor' }); + + await permit.api.resourceRoles.assignPermissions(RESOURCE, 'editor', permissions); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(`${COLLECTION}/editor/permissions`); + expect(rest.last?.data).toEqual({ permissions }); + }); + }); + + describe('removePermissions', () => { + it('DELETEs the permissions body from the role permissions path', async () => { + const permissions = ['doc:write']; + rest.resolveWith({ key: 'editor' }); + + await permit.api.resourceRoles.removePermissions(RESOURCE, 'editor', permissions); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/editor/permissions`); + expect(rest.last?.data).toEqual({ permissions }); + }); + }); + + // The role-derivation methods delegate to a different generated client + // (`ImplicitGrantsApi`), which builds an `.../roles/{roleKey}/implicit_grants` + // path under the same project/environment scope. + describe('role derivation (implicit grants)', () => { + const DERIVATION = `${COLLECTION}/editor/implicit_grants`; + + describe('createRoleDerivation', () => { + const rule: DerivedRoleRuleCreate = { + role: 'viewer', + on_resource: 'folder', + linked_by_relation: 'parent', + }; + + it('POSTs the derivation rule to the role implicit-grants path', async () => { + rest.resolveWith({ ...rule }); + + await permit.api.resourceRoles.createRoleDerivation(RESOURCE, 'editor', rule); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(DERIVATION); + expect(rest.last?.data).toEqual(rule); + }); + + it('maps a 404 to PermitApiError', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceRoles + .createRoleDerivation(RESOURCE, 'editor', rule) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resourceRoles + .createRoleDerivation(RESOURCE, 'editor', rule) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); + + describe('deleteRoleDerivation', () => { + const rule: DerivedRoleRuleDelete = { + role: 'viewer', + on_resource: 'folder', + linked_by_relation: 'parent', + }; + + it('DELETEs the derivation rule from the role implicit-grants path', async () => { + rest.resolveWith({}); + + await permit.api.resourceRoles.deleteRoleDerivation(RESOURCE, 'editor', rule); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(DERIVATION); + expect(rest.last?.data).toEqual(rule); + }); + + it('maps a 404 to PermitApiError', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceRoles + .deleteRoleDerivation(RESOURCE, 'editor', rule) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + }); + + describe('updateRoleDerivationConditions', () => { + const conditions: PermitBackendSchemasSchemaDerivedRoleDerivedRoleSettings = { + no_direct_roles_on_object: true, + }; + + it('PUTs the conditions to the implicit-grants conditions path', async () => { + rest.resolveWith({ ...conditions }); + + await permit.api.resourceRoles.updateRoleDerivationConditions( + RESOURCE, + 'editor', + conditions, + ); + + expect(rest.last?.method).toBe('PUT'); + expect(rest.last?.url).toContain(`${DERIVATION}/conditions`); + expect(rest.last?.data).toEqual(conditions); + }); + + it('maps a 404 to PermitApiError', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceRoles + .updateRoleDerivationConditions(RESOURCE, 'editor', conditions) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resourceRoles.get(RESOURCE, 'missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resourceRoles + .create(RESOURCE, { key: 'editor', name: 'Editor' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/resources.spec.ts b/src/tests/unit/api/resources.spec.ts new file mode 100644 index 0000000..13b9009 --- /dev/null +++ b/src/tests/unit/api/resources.spec.ts @@ -0,0 +1,153 @@ +import { PermitApiError } from '../../../api/base'; +import { ResourceCreate, ResourceReplace, ResourceUpdate } from '../../../api/resources'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// resources URL is scoped under `/v2/schema/{proj}/{env}/resources`. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/resources`; + +describe('ResourcesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.resources.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // page/per_page are serialized into the URL query string (values are strings). + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + // includeTotalCount is omitted when not requested. + expect(rest.last?.params).not.toHaveProperty('include_total_count'); + }); + + it('forwards page, perPage and includeTotalCount as wire params', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.resources.list({ page: 2, perPage: 5, includeTotalCount: true }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ + page: '2', + per_page: '5', + include_total_count: 'true', + }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single resource with the key in the path', async () => { + rest.resolveWith({ key: 'doc' }); + + await permit.api.resources.get('doc'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/doc`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'doc' }); + + await permit.api.resources.getByKey('doc'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/doc`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'res-1' }); + + await permit.api.resources.getById('res-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/res-1`); + }); + }); + + describe('create', () => { + const payload: ResourceCreate = { + key: 'doc', + name: 'Document', + actions: { read: {}, write: {} }, + }; + + it('POSTs the resource body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'res-1' }); + + await permit.api.resources.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the resource body to the keyed path', async () => { + const body: ResourceUpdate = { name: 'Renamed' }; + rest.resolveWith({ key: 'doc', name: 'Renamed' }); + + await permit.api.resources.update('doc', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/doc`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('replace', () => { + it('PUTs the resource body to the keyed path', async () => { + const body: ResourceReplace = { name: 'Replaced', actions: { read: {} } }; + rest.resolveWith({ key: 'doc' }); + + await permit.api.resources.replace('doc', body); + + expect(rest.last?.method).toBe('PUT'); + expect(rest.last?.url).toContain(`${COLLECTION}/doc`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.resources.delete('doc'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/doc`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.resources.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.resources + .create({ key: 'doc', name: 'Document', actions: { read: {} } }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/role-assignments.spec.ts b/src/tests/unit/api/role-assignments.spec.ts new file mode 100644 index 0000000..182141c --- /dev/null +++ b/src/tests/unit/api/role-assignments.spec.ts @@ -0,0 +1,229 @@ +import { PermitApiError } from '../../../api/base'; +import { RoleAssignmentCreate, RoleAssignmentRemove } from '../../../api/role-assignments'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// role-assignments URL is scoped under `/v2/facts/{proj}/{env}/role_assignments`. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/facts/${PROJ}/${ENV}/role_assignments`; +const BULK = `${COLLECTION}/bulk`; + +// waitForSync reads the X-Wait-Timeout header off a cloned client's openapi +// config (see wait-for-sync.spec.ts); this shape exposes that protected member. +interface ApiWithConfig { + openapiClientConfig: { basePath?: string; baseOptions: { headers: Record } }; +} + +function headersOf(api: unknown): Record { + return (api as unknown as ApiWithConfig).openapiClientConfig.baseOptions.headers; +} + +describe('RoleAssignmentsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.roleAssignments.list({}); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // page/per_page are always sent (SDK defaults 1/100) and serialized as strings. + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + // Optional flags are omitted unless requested. + expect(rest.last?.params).not.toHaveProperty('include_total_count'); + expect(rest.last?.params).not.toHaveProperty('detailed'); + }); + + it('forwards user/role/tenant/resourceInstance filters as snake_case wire params', async () => { + rest.resolveWith([]); + + await permit.api.roleAssignments.list({ + user: 'user-1', + role: 'admin', + tenant: 'acme', + resourceInstance: 'document:readme', + }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ + user: 'user-1', + role: 'admin', + tenant: 'acme', + resource_instance: 'document:readme', + }); + }); + + it('forwards page, perPage and includeTotalCount as wire params', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.roleAssignments.list({ page: 2, perPage: 5, includeTotalCount: true }); + + expect(rest.last?.params).toMatchObject({ + page: '2', + per_page: '5', + include_total_count: 'true', + }); + }); + + it('forwards the detailed flag as a wire param', async () => { + rest.resolveWith([]); + + await permit.api.roleAssignments.list({ detailed: true }); + + expect(rest.last?.params).toMatchObject({ detailed: 'true' }); + }); + }); + + describe('assign', () => { + const payload: RoleAssignmentCreate = { + user: 'user-1', + role: 'admin', + tenant: 'acme', + }; + + it('POSTs the assignment body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'ra-1' }); + + await permit.api.roleAssignments.assign(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.url).not.toContain('/bulk'); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('unassign', () => { + const payload: RoleAssignmentRemove = { + user: 'user-1', + role: 'admin', + tenant: 'acme', + }; + + it('DELETEs the collection with the unassignment body', async () => { + rest.resolveWith({}); + + await permit.api.roleAssignments.unassign(payload); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.url).not.toContain('/bulk'); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('bulkAssign', () => { + const payload: RoleAssignmentCreate[] = [ + { user: 'user-1', role: 'admin', tenant: 'acme' }, + { user: 'user-2', role: 'viewer', tenant: 'acme' }, + ]; + + it('POSTs the array of assignments to the bulk path', async () => { + rest.resolveWith({ assignments: [] }); + + await permit.api.roleAssignments.bulkAssign(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(BULK); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('bulkUnassign', () => { + const payload: RoleAssignmentRemove[] = [ + { user: 'user-1', role: 'admin', tenant: 'acme' }, + { user: 'user-2', role: 'viewer', tenant: 'acme' }, + ]; + + it('DELETEs the array of unassignments at the bulk path', async () => { + rest.resolveWith({ assignments: [] }); + + await permit.api.roleAssignments.bulkUnassign(payload); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(BULK); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('waitForSync', () => { + it('clones the client with X-Wait-Timeout and routes facts through the PDP host', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + const synced = proxied.permit.api.roleAssignments.waitForSync(10); + + // A distinct clone carries the wait header; the original is untouched. + expect(synced).not.toBe(proxied.permit.api.roleAssignments); + expect(headersOf(synced)['X-Wait-Timeout']).toBe('10'); + expect(headersOf(proxied.permit.api.roleAssignments)['X-Wait-Timeout']).toBeUndefined(); + + // The proxied facts client still dispatches on the REST transport, but the + // absolute URL now targets the PDP host rather than the control-plane API. + proxied.rest.resolveWith({ id: 'ra-1' }); + await synced.assign({ user: 'user-1', role: 'admin', tenant: 'acme' }); + + expect(proxied.rest.last?.method).toBe('POST'); + expect(proxied.rest.last?.url).toContain('http://localhost:7766'); + expect(proxied.rest.last?.url).toContain(COLLECTION); + }); + + it('is a no-op without proxyFactsViaPdp (returns self, no wait header)', () => { + const synced = permit.api.roleAssignments.waitForSync(0); + + expect(synced).toBe(permit.api.roleAssignments); + expect(headersOf(synced)['X-Wait-Timeout']).toBeUndefined(); + }); + }); + + describe('error mapping', () => { + it('maps a 404 on list to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.roleAssignments.list({}).catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 404 on unassign to PermitApiError', async () => { + rest.rejectWith(404, { message: 'assignment not found' }); + + const error = await permit.api.roleAssignments + .unassign({ user: 'user-1', role: 'admin', tenant: 'acme' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on assign to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already assigned' }); + + const error = await permit.api.roleAssignments + .assign({ user: 'user-1', role: 'admin', tenant: 'acme' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + + it('maps a 422 on bulkAssign to PermitApiError', async () => { + rest.rejectWith(422, { message: 'validation error' }); + + const error = await permit.api.roleAssignments + .bulkAssign([{ user: 'user-1', role: 'admin', tenant: 'acme' }]) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(422); + }); + }); +}); diff --git a/src/tests/unit/api/roles.spec.ts b/src/tests/unit/api/roles.spec.ts new file mode 100644 index 0000000..5ade81b --- /dev/null +++ b/src/tests/unit/api/roles.spec.ts @@ -0,0 +1,164 @@ +import { PermitApiError } from '../../../api/base'; +import { RoleCreate, RoleUpdate } from '../../../api/roles'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// roles URL is scoped under `/v2/schema/{proj}/{env}/roles`. +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/schema/${PROJ}/${ENV}/roles`; + +describe('RolesApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection with default pagination', async () => { + rest.resolveWith([]); + + await permit.api.roles.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ page: '1', per_page: '100' }); + expect(rest.last?.params).not.toHaveProperty('include_total_count'); + }); + + it('forwards page, perPage and includeTotalCount as wire params', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.roles.list({ page: 3, perPage: 25, includeTotalCount: true }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.params).toMatchObject({ + page: '3', + per_page: '25', + include_total_count: 'true', + }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single role with the key in the path', async () => { + rest.resolveWith({ key: 'admin' }); + + await permit.api.roles.get('admin'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/admin`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'admin' }); + + await permit.api.roles.getByKey('admin'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/admin`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'role-1' }); + + await permit.api.roles.getById('role-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/role-1`); + }); + }); + + describe('create', () => { + const payload: RoleCreate = { + key: 'admin', + name: 'Administrator', + permissions: ['doc:read', 'doc:write'], + }; + + it('POSTs the role body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'role-1' }); + + await permit.api.roles.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the role body to the keyed path', async () => { + const body: RoleUpdate = { name: 'Renamed' }; + rest.resolveWith({ key: 'admin', name: 'Renamed' }); + + await permit.api.roles.update('admin', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/admin`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.roles.delete('admin'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/admin`); + }); + }); + + describe('assignPermissions', () => { + it('POSTs the permissions body to the role permissions path', async () => { + const permissions = ['doc:read', 'doc:write']; + rest.resolveWith({ key: 'admin' }); + + await permit.api.roles.assignPermissions('admin', permissions); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(`${COLLECTION}/admin/permissions`); + expect(rest.last?.data).toEqual({ permissions }); + }); + }); + + describe('removePermissions', () => { + it('DELETEs the permissions body from the role permissions path', async () => { + const permissions = ['doc:write']; + rest.resolveWith({ key: 'admin' }); + + await permit.api.roles.removePermissions('admin', permissions); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/admin/permissions`); + expect(rest.last?.data).toEqual({ permissions }); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.roles.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.roles + .create({ key: 'admin', name: 'Administrator' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/tenants.spec.ts b/src/tests/unit/api/tenants.spec.ts new file mode 100644 index 0000000..9564556 --- /dev/null +++ b/src/tests/unit/api/tenants.spec.ts @@ -0,0 +1,222 @@ +import { PermitApiError } from '../../../api/base'; +import { TenantCreate, TenantUpdate } from '../../../api/tenants'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// tenants URL is scoped under `/v2/facts/{proj}/{env}/tenants`. Tenants is a +// facts module, hence the `/v2/facts` prefix (not `/v2/schema`). +const PROJ = 'proj'; +const ENV = 'env'; +const COLLECTION = `/v2/facts/${PROJ}/${ENV}/tenants`; + +describe('TenantsApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection without injecting pagination defaults', async () => { + rest.resolveWith([]); + + await permit.api.tenants.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + // Unlike resources, tenants.list() forwards only the params it is given, + // so page/per_page are absent when the caller omits them. + expect(rest.last?.params).not.toHaveProperty('page'); + expect(rest.last?.params).not.toHaveProperty('per_page'); + }); + + it('forwards search, page and perPage as wire params', async () => { + rest.resolveWith([]); + + await permit.api.tenants.list({ page: 2, perPage: 5, search: 'acme' }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.params).toMatchObject({ + page: '2', + per_page: '5', + search: 'acme', + }); + }); + }); + + describe('listTenantUsers', () => { + it('GETs the tenant users sub-collection with the tenant key in the path', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.tenants.listTenantUsers({ tenantKey: 't1' }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1/users`); + expect(rest.last?.params).not.toHaveProperty('page'); + }); + + it('forwards pagination and the search/role filters as wire params', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.tenants.listTenantUsers({ + tenantKey: 't1', + page: 3, + perPage: 10, + search: 'alice', + role: 'admin', + }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1/users`); + expect(rest.last?.params).toMatchObject({ + page: '3', + per_page: '10', + search: 'alice', + role: 'admin', + }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single tenant with the key in the path', async () => { + rest.resolveWith({ key: 't1' }); + + await permit.api.tenants.get('t1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 't1' }); + + await permit.api.tenants.getByKey('t1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'tenant-id' }); + + await permit.api.tenants.getById('tenant-id'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${COLLECTION}/tenant-id`); + }); + }); + + describe('create', () => { + const payload: TenantCreate = { + key: 't1', + name: 'Acme', + attributes: { tier: 'gold' }, + }; + + it('POSTs the tenant body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'tenant-1' }); + + await permit.api.tenants.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(COLLECTION); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the tenant body to the keyed path', async () => { + const body: TenantUpdate = { name: 'Renamed' }; + rest.resolveWith({ key: 't1', name: 'Renamed' }); + + await permit.api.tenants.update('t1', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.tenants.delete('t1'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1`); + }); + }); + + describe('deleteTenantUser', () => { + it('DELETEs the user under the tenant with both keys in the path', async () => { + rest.resolveWith({}); + + await permit.api.tenants.deleteTenantUser('t1', 'u1'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${COLLECTION}/t1/users/u1`); + }); + }); + + describe('waitForSync', () => { + it('returns the same instance and dispatches to the REST host when proxy is off', async () => { + const tenants = permit.api.tenants; + + expect(tenants.waitForSync(10)).toBe(tenants); + + rest.resolveWith([]); + await tenants.list(); + expect(rest.last?.url).toContain('http://localhost:8000'); + }); + + it('returns a distinct clone that dispatches to the PDP host when proxy is on', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + const tenants = proxied.permit.api.tenants; + + const synced = tenants.waitForSync(10); + expect(synced).not.toBe(tenants); + + proxied.rest.resolveWith([]); + await synced.list(); + expect(proxied.rest.last?.url).toContain('http://localhost:7766/v2/facts'); + }); + }); + + describe('proxyFactsViaPdp', () => { + it('routes the request through the PDP host while still using the rest transport', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + proxied.rest.resolveWith([]); + + await proxied.permit.api.tenants.list(); + + expect(proxied.rest.last?.method).toBe('GET'); + expect(proxied.rest.last?.url).toContain('http://localhost:7766/v2/facts/proj/env/tenants'); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.tenants.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.tenants + .create({ key: 't1', name: 'Acme' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/api/users.spec.ts b/src/tests/unit/api/users.spec.ts new file mode 100644 index 0000000..1940e7f --- /dev/null +++ b/src/tests/unit/api/users.spec.ts @@ -0,0 +1,328 @@ +import { PermitApiError } from '../../../api/base'; +import { + RoleAssignmentCreate, + RoleAssignmentRemove, + UserCreate, + UserUpdate, +} from '../../../api/users'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The mock seeds an environment-level context with these defaults, so every +// users URL is scoped under `/v2/facts/{proj}/{env}/users`. +const PROJ = 'proj'; +const ENV = 'env'; +const USERS = `/v2/facts/${PROJ}/${ENV}/users`; +const ROLE_ASSIGNMENTS = `/v2/facts/${PROJ}/${ENV}/role_assignments`; +const BULK_USERS = `/v2/facts/${PROJ}/${ENV}/bulk/users`; + +describe('UsersApi (unit)', () => { + let permit: Permit; + let rest: MockTransport; + + beforeEach(() => { + ({ permit, rest } = createMockPermit()); + }); + + describe('list', () => { + it('GETs the env-scoped collection without pagination params by default', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.users.list(); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(USERS); + // Unlike resources.list, users.list does not inject default page/per_page. + expect(rest.last?.params).not.toHaveProperty('page'); + expect(rest.last?.params).not.toHaveProperty('per_page'); + }); + + it('forwards search, role and pagination as wire params', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.users.list({ search: 'bob', role: 'admin', page: 2, perPage: 5 }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(USERS); + expect(rest.last?.params).toMatchObject({ + search: 'bob', + role: 'admin', + page: '2', + per_page: '5', + }); + }); + }); + + describe('get / getByKey / getById', () => { + it('GETs a single user with the key in the path', async () => { + rest.resolveWith({ key: 'bob' }); + + await permit.api.users.get('bob'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${USERS}/bob`); + }); + + it('getByKey is an alias for get', async () => { + rest.resolveWith({ key: 'bob' }); + + await permit.api.users.getByKey('bob'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${USERS}/bob`); + }); + + it('getById is an alias for get', async () => { + rest.resolveWith({ key: 'user-1' }); + + await permit.api.users.getById('user-1'); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(`${USERS}/user-1`); + }); + }); + + describe('create', () => { + const payload: UserCreate = { + key: 'bob', + email: 'bob@example.com', + first_name: 'Bob', + }; + + it('POSTs the user body to the collection', async () => { + rest.resolveWith({ ...payload, id: 'user-1' }, 201); + + await permit.api.users.create(payload); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(USERS); + expect(rest.last?.data).toEqual(payload); + }); + }); + + describe('update', () => { + it('PATCHes the user body to the keyed path', async () => { + const body: UserUpdate = { first_name: 'Robert' }; + rest.resolveWith({ key: 'bob', first_name: 'Robert' }); + + await permit.api.users.update('bob', body); + + expect(rest.last?.method).toBe('PATCH'); + expect(rest.last?.url).toContain(`${USERS}/bob`); + expect(rest.last?.data).toEqual(body); + }); + }); + + describe('sync', () => { + const payload: UserCreate = { key: 'bob', email: 'bob@example.com' }; + + it('PUTs the user body to the keyed path', async () => { + rest.resolveWith({ ...payload, id: 'user-1' }, 200); + + await permit.api.users.sync(payload); + + expect(rest.last?.method).toBe('PUT'); + expect(rest.last?.url).toContain(`${USERS}/bob`); + expect(rest.last?.data).toEqual(payload); + }); + + it('reports created=true when the API responds 201', async () => { + rest.resolveWith({ ...payload, id: 'user-1' }, 201); + + const result = await permit.api.users.sync(payload); + + expect(result.created).toBe(true); + expect(result.user).toMatchObject({ key: 'bob' }); + }); + + it('reports created=false when the API responds 200', async () => { + rest.resolveWith({ ...payload, id: 'user-1' }, 200); + + const result = await permit.api.users.sync(payload); + + expect(result.created).toBe(false); + expect(result.user).toMatchObject({ key: 'bob' }); + }); + }); + + describe('delete', () => { + it('DELETEs the keyed path', async () => { + rest.resolveWith({}); + + await permit.api.users.delete('bob'); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(`${USERS}/bob`); + }); + }); + + describe('assignRole / unassignRole', () => { + it('POSTs the assignment to the role_assignments path', async () => { + const assignment: RoleAssignmentCreate = { user: 'bob', role: 'admin', tenant: 'acme' }; + rest.resolveWith({ ...assignment, id: 'ra-1' }); + + await permit.api.users.assignRole(assignment); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(ROLE_ASSIGNMENTS); + expect(rest.last?.data).toEqual(assignment); + }); + + it('DELETEs the removal from the role_assignments path', async () => { + const removal: RoleAssignmentRemove = { user: 'bob', role: 'admin', tenant: 'acme' }; + rest.resolveWith({}); + + await permit.api.users.unassignRole(removal); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(ROLE_ASSIGNMENTS); + expect(rest.last?.data).toEqual(removal); + }); + }); + + describe('getAssignedRoles', () => { + it('GETs role_assignments with default detailed/includeTotalCount/pagination flags', async () => { + rest.resolveWith([]); + + await permit.api.users.getAssignedRoles({ user: 'bob' }); + + expect(rest.last?.method).toBe('GET'); + expect(rest.last?.url).toContain(ROLE_ASSIGNMENTS); + expect(rest.last?.params).toMatchObject({ + user: 'bob', + detailed: 'false', + include_total_count: 'false', + page: '1', + per_page: '100', + }); + expect(rest.last?.params).not.toHaveProperty('tenant'); + }); + + it('forwards the tenant filter', async () => { + rest.resolveWith([]); + + await permit.api.users.getAssignedRoles({ user: 'bob', tenant: 'acme' }); + + expect(rest.last?.params).toMatchObject({ user: 'bob', tenant: 'acme' }); + }); + + it('forwards detailed=true', async () => { + rest.resolveWith([]); + + await permit.api.users.getAssignedRoles({ user: 'bob', detailed: true }); + + expect(rest.last?.params).toMatchObject({ user: 'bob', detailed: 'true' }); + }); + + it('forwards includeTotalCount and explicit pagination', async () => { + rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + + await permit.api.users.getAssignedRoles({ + user: 'bob', + includeTotalCount: true, + page: 3, + perPage: 20, + }); + + expect(rest.last?.params).toMatchObject({ + user: 'bob', + include_total_count: 'true', + page: '3', + per_page: '20', + }); + }); + }); + + describe('bulk operations', () => { + const users: UserCreate[] = [ + { key: 'bob', email: 'bob@example.com' }, + { key: 'alice', email: 'alice@example.com' }, + ]; + + it('POSTs bulkUserCreate with an operations envelope', async () => { + rest.resolveWith({}); + + await permit.api.users.bulkUserCreate(users); + + expect(rest.last?.method).toBe('POST'); + expect(rest.last?.url).toContain(BULK_USERS); + expect(rest.last?.data).toEqual({ operations: users }); + }); + + it('PUTs bulkUserReplace with an operations envelope', async () => { + rest.resolveWith({}); + + await permit.api.users.bulkUserReplace(users); + + expect(rest.last?.method).toBe('PUT'); + expect(rest.last?.url).toContain(BULK_USERS); + expect(rest.last?.data).toEqual({ operations: users }); + }); + + it('DELETEs bulkUserDelete with an idents envelope', async () => { + rest.resolveWith({}); + + await permit.api.users.bulkUserDelete(['bob', 'alice']); + + expect(rest.last?.method).toBe('DELETE'); + expect(rest.last?.url).toContain(BULK_USERS); + expect(rest.last?.data).toEqual({ idents: ['bob', 'alice'] }); + }); + }); + + describe('waitForSync', () => { + it('returns the same instance and ignores the call when proxyFactsViaPdp is off', () => { + const synced = permit.api.users.waitForSync(10); + + expect(synced).toBe(permit.api.users); + }); + + it('returns a clone that dispatches through the PDP host when proxyFactsViaPdp is on', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + + const synced = proxied.permit.api.users.waitForSync(10); + expect(synced).not.toBe(proxied.permit.api.users); + + proxied.rest.resolveWith({ data: [], total_count: 0, page_count: 0 }); + await synced.list(); + + expect(proxied.rest.last?.method).toBe('GET'); + expect(proxied.rest.last?.url).toContain('http://localhost:7766'); + expect(proxied.rest.last?.url).toContain(USERS); + }); + }); + + describe('proxyFactsViaPdp', () => { + it('routes facts requests through the PDP base path while still using the rest transport', async () => { + const proxied = createMockPermit({ proxyFactsViaPdp: true }); + proxied.rest.resolveWith({ key: 'bob' }); + + await proxied.permit.api.users.get('bob'); + + expect(proxied.rest.last?.url).toContain('http://localhost:7766'); + expect(proxied.rest.last?.url).toContain(`${USERS}/bob`); + }); + }); + + describe('error mapping', () => { + it('maps a 404 to PermitApiError carrying the upstream response', async () => { + rest.rejectWith(404, { message: 'not found' }); + + const error = await permit.api.users.get('missing').catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(404); + }); + + it('maps a 409 conflict on create to PermitApiError', async () => { + rest.rejectWith(409, { message: 'already exists' }); + + const error = await permit.api.users + .create({ key: 'bob', email: 'bob@example.com' }) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitApiError); + expect(error.response?.status).toBe(409); + }); + }); +}); diff --git a/src/tests/unit/config.spec.ts b/src/tests/unit/config.spec.ts new file mode 100644 index 0000000..2a2d952 --- /dev/null +++ b/src/tests/unit/config.spec.ts @@ -0,0 +1,129 @@ +import { ApiContext } from '../../api/context'; +import { ConfigFactory } from '../../config'; + +const ENV_KEYS = [ + 'PERMIT_API_KEY', + 'PERMIT_PDP_URL', + 'PERMIT_API_URL', + 'PERMIT_LOG_LEVEL', + 'PERMIT_LOG_LABEL', + 'PERMIT_LOG_JSON', +] as const; + +describe('ConfigFactory (unit)', () => { + let saved: Record; + + beforeEach(() => { + // Snapshot then clear the env so each test starts from the bare defaults. + saved = {}; + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + describe('defaults', () => { + it('returns the documented defaults when no env vars are set', () => { + const config = ConfigFactory.defaults(); + + expect(config.token).toBe(''); + expect(config.pdp).toBe('http://localhost:7766'); + expect(config.apiUrl).toBe('https://api.permit.io'); + expect(config.log).toEqual({ level: 'warn', label: 'Permit.io', json: false }); + expect(config.multiTenancy).toEqual({ + defaultTenant: 'default', + useDefaultTenantIfEmpty: true, + }); + expect(config.timeout).toBeUndefined(); + expect(config.throwOnError).toBe(true); + expect(config.proxyFactsViaPdp).toBe(false); + expect(config.factsSyncTimeout).toBeNull(); + expect(config.factsSyncTimeoutPolicy).toBeNull(); + expect(config.apiContext).toBeInstanceOf(ApiContext); + expect(config.axiosInstance).toBeDefined(); + }); + + it('reads scalar overrides from the environment', () => { + process.env.PERMIT_API_KEY = 'env-key'; + process.env.PERMIT_PDP_URL = 'http://pdp.local:7000'; + process.env.PERMIT_API_URL = 'http://api.local:8000'; + process.env.PERMIT_LOG_LEVEL = 'debug'; + process.env.PERMIT_LOG_LABEL = 'MyLabel'; + + const config = ConfigFactory.defaults(); + + expect(config.token).toBe('env-key'); + expect(config.pdp).toBe('http://pdp.local:7000'); + expect(config.apiUrl).toBe('http://api.local:8000'); + expect(config.log.level).toBe('debug'); + expect(config.log.label).toBe('MyLabel'); + }); + + it('parses PERMIT_LOG_JSON into a boolean', () => { + process.env.PERMIT_LOG_JSON = 'true'; + expect(ConfigFactory.defaults().log.json).toBe(true); + + process.env.PERMIT_LOG_JSON = 'false'; + expect(ConfigFactory.defaults().log.json).toBe(false); + }); + + it('throws when PERMIT_LOG_JSON is not valid JSON', () => { + // JSON.parse('maybe') throws; defaults() surfaces that rather than guessing. + process.env.PERMIT_LOG_JSON = 'maybe'; + + expect(() => ConfigFactory.defaults()).toThrow(); + }); + }); + + describe('build', () => { + it('returns the defaults when given an empty partial', () => { + const config = ConfigFactory.build({}); + + expect(config.token).toBe(''); + expect(config.pdp).toBe('http://localhost:7766'); + expect(config.log).toEqual({ level: 'warn', label: 'Permit.io', json: false }); + }); + + it('overrides top-level fields while leaving the rest at their defaults', () => { + const config = ConfigFactory.build({ pdp: 'http://pdp:1234', proxyFactsViaPdp: true }); + + expect(config.pdp).toBe('http://pdp:1234'); + expect(config.proxyFactsViaPdp).toBe(true); + expect(config.apiUrl).toBe('https://api.permit.io'); + }); + + it('deep-merges a nested log partial without dropping sibling defaults', () => { + const config = ConfigFactory.build({ log: { level: 'debug' } }); + + expect(config.log.level).toBe('debug'); + // Siblings survive the merge (proves a deep merge, not a shallow replace). + expect(config.log.label).toBe('Permit.io'); + expect(config.log.json).toBe(false); + }); + + it('deep-merges a nested multiTenancy partial without dropping sibling defaults', () => { + const config = ConfigFactory.build({ multiTenancy: { defaultTenant: 'tenant-x' } }); + + expect(config.multiTenancy.defaultTenant).toBe('tenant-x'); + expect(config.multiTenancy.useDefaultTenantIfEmpty).toBe(true); + }); + + it('layers an explicit token over the env-derived default', () => { + process.env.PERMIT_API_KEY = 'env-key'; + + expect(ConfigFactory.build({}).token).toBe('env-key'); + expect(ConfigFactory.build({ token: 'explicit' }).token).toBe('explicit'); + }); + }); +}); diff --git a/src/tests/unit/enforcement/enforcer.spec.ts b/src/tests/unit/enforcement/enforcer.spec.ts new file mode 100644 index 0000000..4280a45 --- /dev/null +++ b/src/tests/unit/enforcement/enforcer.spec.ts @@ -0,0 +1,415 @@ +import { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +import { PermitConnectionError, PermitPDPStatusError } from '../../../enforcement/enforcer'; +import { ICheckQuery } from '../../../enforcement/interfaces'; +import { Permit } from '../../../index'; +import { createMockPermit, MockTransport } from '../../helpers/mock-api'; + +// The enforcer talks to two dedicated axios instances: the PDP client (captured +// by `pdp`) and the OPA client (captured by `opa`, used only when a check is +// made with `{ useOpa: true }`). Both default to the seeded pdp host +// (http://localhost:7766/, OPA rewritten to port 8181 + /v1/data/permit/). +describe('Enforcer (unit)', () => { + let permit: Permit; + let pdp: MockTransport; + let opa: MockTransport; + + beforeEach(() => { + ({ permit, pdp, opa } = createMockPermit()); + }); + + describe('check - request shaping', () => { + it('POSTs to `allowed` with the full {user,action,resource,context} input', async () => { + pdp.resolveWith({ allow: true }); + + const allowed = await permit.check( + { key: 'alice', email: 'alice@x.com' }, + 'read', + { type: 'doc', key: 'd1', tenant: 't1' }, + { region: 'eu' }, + ); + + expect(allowed).toBe(true); + expect(pdp.last?.method).toBe('POST'); + expect(pdp.last?.url).toBe('allowed'); + expect(pdp.last?.data).toEqual({ + user: { key: 'alice', email: 'alice@x.com' }, + action: 'read', + resource: { type: 'doc', key: 'd1', tenant: 't1' }, + context: { region: 'eu' }, + }); + }); + + it('wraps a string user into { key }', async () => { + pdp.resolveWith({ allow: true }); + + await permit.check('alice', 'read', { type: 'doc', tenant: 't1' }); + + expect(pdp.last?.data?.user).toEqual({ key: 'alice' }); + }); + + it('parses a `type:key` resource string into { type, key }', async () => { + pdp.resolveWith({ allow: true }); + + await permit.check('alice', 'read', 'doc:123'); + + expect(pdp.last?.data?.resource).toEqual({ type: 'doc', key: '123', tenant: 'default' }); + }); + + it('parses a bare `type` resource string into { type } with no key', async () => { + pdp.resolveWith({ allow: true }); + + await permit.check('alice', 'read', 'doc'); + + // `key` is `undefined`, so JSON serialization drops it from the body. + expect(pdp.last?.data?.resource).toEqual({ type: 'doc', tenant: 'default' }); + expect(pdp.last?.data?.resource).not.toHaveProperty('key'); + }); + + it('throws `invalid resource string` for >2 colon-separated parts and never dispatches', async () => { + pdp.resolveWith({ allow: true }); + + const error = await permit.check('alice', 'read', 'a:b:c').catch((err) => err); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('invalid resource string'); + expect(pdp.requests.length).toBe(0); + }); + }); + + describe('check - default tenant injection', () => { + it('injects the default tenant when the resource has none and useDefaultTenantIfEmpty is on', async () => { + pdp.resolveWith({ allow: true }); + + await permit.check('alice', 'read', { type: 'doc' }); + + expect(pdp.last?.data?.resource).toEqual({ type: 'doc', tenant: 'default' }); + }); + + it('keeps the resource tenant untouched when one is already set', async () => { + pdp.resolveWith({ allow: true }); + + await permit.check('alice', 'read', { type: 'doc', tenant: 'acme' }); + + expect(pdp.last?.data?.resource?.tenant).toBe('acme'); + }); + + it('does not inject a tenant when useDefaultTenantIfEmpty is off', async () => { + permit.config.multiTenancy.useDefaultTenantIfEmpty = false; + pdp.resolveWith({ allow: true }); + + await permit.check('alice', 'read', { type: 'doc' }); + + expect(pdp.last?.data?.resource).toEqual({ type: 'doc' }); + }); + }); + + describe('check - OPA path', () => { + it('POSTs to `root` on the OPA client with an { input } wrapper', async () => { + opa.resolveWith({ result: { allow: true } }); + + const allowed = await permit.check( + 'alice', + 'read', + { type: 'doc', tenant: 't1' }, + {}, + { useOpa: true }, + ); + + expect(allowed).toBe(true); + expect(opa.last?.method).toBe('POST'); + expect(opa.last?.url).toBe('root'); + expect(opa.last?.baseURL).toContain('8181'); + expect(opa.last?.baseURL).toContain('v1/data/permit'); + expect(opa.last?.data).toEqual({ + input: { + user: { key: 'alice' }, + action: 'read', + resource: { type: 'doc', tenant: 't1' }, + context: {}, + }, + }); + // The OPA path must not touch the PDP client. + expect(pdp.requests.length).toBe(0); + }); + }); + + describe('check - response shaping', () => { + it('returns the boolean from a plain `{ allow }` PDP decision', async () => { + pdp.resolveWith({ allow: false }); + + expect(await permit.check('alice', 'read', 'doc')).toBe(false); + }); + + it('unwraps `{ result: { allow } }` from an OPA decision', async () => { + opa.resolveWith({ result: { allow: false } }); + + expect(await permit.check('alice', 'read', 'doc', {}, { useOpa: true })).toBe(false); + }); + }); + + describe('check - error handling', () => { + // A non-200 PDP response makes the SDK throw a PermitPDPStatusError inside the + // `.then`, but the trailing `.catch` re-wraps every rejection into a + // PermitConnectionError, so PermitPDPStatusError never actually surfaces to + // the caller. We assert the behavior the SDK exhibits today. + it('surfaces a non-200 PDP response as PermitConnectionError', async () => { + pdp.resolveWith({ allow: true }, 502); + + const error = await permit.check('alice', 'read', 'doc').catch((err) => err); + + expect(error).toBeInstanceOf(PermitConnectionError); + expect(error).not.toBeInstanceOf(PermitPDPStatusError); + expect(error.message).toContain('unexpected status code'); + }); + + it('maps a network/HTTP rejection to PermitConnectionError', async () => { + pdp.rejectWith(500, { message: 'boom' }); + + const error = await permit.check('alice', 'read', 'doc').catch((err) => err); + + expect(error).toBeInstanceOf(PermitConnectionError); + }); + + it('swallows the error and returns false when throwOnError is false per-call', async () => { + pdp.rejectWith(500, { message: 'boom' }); + + const allowed = await permit.check('alice', 'read', 'doc', {}, { throwOnError: false }); + + expect(allowed).toBe(false); + }); + + it('swallows the error and returns false when throwOnError is false in config', async () => { + permit.config.throwOnError = false; + pdp.rejectWith(500, { message: 'boom' }); + + expect(await permit.check('alice', 'read', 'doc')).toBe(false); + }); + }); + + describe('check - timeout passthrough', () => { + it('forwards the per-call timeout to the PDP request config', async () => { + const { client } = (permit as unknown as { enforcer: { client: AxiosInstance } }).enforcer; + let seenTimeout: number | undefined; + client.defaults.adapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + seenTimeout = config.timeout; + return { data: { allow: true }, status: 200, statusText: 'OK', headers: {}, config }; + }; + + await permit.check('alice', 'read', 'doc', {}, { timeout: 1234 }); + + expect(seenTimeout).toBe(1234); + }); + }); + + describe('bulkCheck', () => { + it('POSTs the mapped inputs to `allowed/bulk` and returns one boolean per check', async () => { + pdp.resolveWith({ allow: [{ allow: true }, { allow: false }] }); + const checks: ICheckQuery[] = [ + { user: 'u1', action: 'read', resource: 'doc' }, + { user: { key: 'u2' }, action: 'write', resource: { type: 'task', key: '1', tenant: 't' } }, + ]; + + const decisions = await permit.bulkCheck(checks, { region: 'eu' }); + + expect(decisions).toEqual([true, false]); + expect(pdp.last?.method).toBe('POST'); + expect(pdp.last?.url).toBe('allowed/bulk'); + expect(pdp.last?.data).toEqual([ + { + user: { key: 'u1' }, + action: 'read', + resource: { type: 'doc', tenant: 'default' }, + context: { region: 'eu' }, + }, + { + user: { key: 'u2' }, + action: 'write', + resource: { type: 'task', key: '1', tenant: 't' }, + context: { region: 'eu' }, + }, + ]); + }); + + it('unwraps an OPA-shaped `{ result: { allow } }` bulk decision', async () => { + pdp.resolveWith({ result: { allow: [{ allow: true }, { allow: false }] } }); + + const decisions = await permit.bulkCheck([ + { user: 'u1', action: 'read', resource: 'doc' }, + { user: 'u2', action: 'read', resource: 'doc' }, + ]); + + expect(decisions).toEqual([true, false]); + }); + + it('returns an empty array for an empty check list', async () => { + pdp.resolveWith({ allow: [] }); + + expect(await permit.bulkCheck([])).toEqual([]); + }); + + it('returns an empty array when throwOnError is false and the PDP errors', async () => { + pdp.rejectWith(500, { message: 'boom' }); + + const decisions = await permit.bulkCheck( + [{ user: 'u1', action: 'read', resource: 'doc' }], + {}, + { throwOnError: false }, + ); + + expect(decisions).toEqual([]); + }); + + it('throws PermitConnectionError on PDP error by default', async () => { + pdp.rejectWith(500, { message: 'boom' }); + + const error = await permit + .bulkCheck([{ user: 'u1', action: 'read', resource: 'doc' }]) + .catch((err) => err); + + expect(error).toBeInstanceOf(PermitConnectionError); + }); + + it('forwards the per-call timeout to the PDP request config', async () => { + const { client } = (permit as unknown as { enforcer: { client: AxiosInstance } }).enforcer; + let seenTimeout: number | undefined; + client.defaults.adapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + seenTimeout = config.timeout; + return { data: { allow: [] }, status: 200, statusText: 'OK', headers: {}, config }; + }; + + await permit.bulkCheck( + [{ user: 'u1', action: 'read', resource: 'doc' }], + {}, + { timeout: 1234 }, + ); + + expect(seenTimeout).toBe(1234); + }); + }); + + describe('getUserPermissions', () => { + it('POSTs the {user,tenants,resources,resource_types} input to `user-permissions`', async () => { + pdp.resolveWith({ 'doc:1': { permissions: ['read'] } }); + + const permissions = await permit.getUserPermissions('bob', ['t1', 't2'], ['doc:1'], ['doc']); + + expect(permissions).toEqual({ 'doc:1': { permissions: ['read'] } }); + expect(pdp.last?.method).toBe('POST'); + expect(pdp.last?.url).toBe('user-permissions'); + expect(pdp.last?.data).toEqual({ + user: { key: 'bob' }, + tenants: ['t1', 't2'], + resources: ['doc:1'], + resource_types: ['doc'], + }); + }); + + it('omits undefined filters and wraps a string user into { key }', async () => { + pdp.resolveWith({}); + + await permit.getUserPermissions('bob'); + + expect(pdp.last?.data).toEqual({ user: { key: 'bob' } }); + }); + + it('unwraps an OPA-shaped `{ result: { permissions } }` response', async () => { + pdp.resolveWith({ result: { permissions: { 'doc:1': { permissions: ['read'] } } } }); + + const permissions = await permit.getUserPermissions('bob'); + + expect(permissions).toEqual({ 'doc:1': { permissions: ['read'] } }); + }); + + it('returns {} when throwOnError is false and the PDP errors', async () => { + pdp.rejectWith(404, { message: 'not found' }); + + const permissions = await permit.getUserPermissions('bob', undefined, undefined, undefined, { + throwOnError: false, + }); + + expect(permissions).toEqual({}); + }); + + it('throws PermitConnectionError on PDP error by default', async () => { + pdp.rejectWith(500, { message: 'boom' }); + + const error = await permit.getUserPermissions('bob').catch((err) => err); + + expect(error).toBeInstanceOf(PermitConnectionError); + }); + + it('forwards the per-call timeout to the PDP request config', async () => { + const { client } = (permit as unknown as { enforcer: { client: AxiosInstance } }).enforcer; + let seenTimeout: number | undefined; + client.defaults.adapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => { + seenTimeout = config.timeout; + return { data: {}, status: 200, statusText: 'OK', headers: {}, config }; + }; + + await permit.getUserPermissions('bob', undefined, undefined, undefined, { timeout: 1234 }); + + expect(seenTimeout).toBe(1234); + }); + }); + + describe('checkAllTenants', () => { + // TODO(PER-15318): asserts current buggy behavior. checkAllTenants passes + // `{ headers, params }` as the POST *body* (axios's 2nd arg) instead of as a + // request config, so the auth header / query params end up serialized into + // the request body and never become real headers or query params, and the + // user/action/resource are sent raw (not normalized). + it('sends headers and params in the request body (not as real headers/params)', async () => { + pdp.resolveWith({ + allowedTenants: [ + { tenant: { key: 't1', attributes: {} } }, + { tenant: { key: 't2', attributes: { plan: 'pro' } } }, + ], + }); + + const tenants = await permit.checkAllTenants( + 'alice', + 'read', + 'document', + { region: 'eu' }, + 'node', + ); + + expect(tenants).toEqual([ + { key: 't1', attributes: {} }, + { key: 't2', attributes: { plan: 'pro' } }, + ]); + expect(pdp.last?.method).toBe('POST'); + expect(pdp.last?.url).toBe('/allowed/all-tenants'); + expect(pdp.last?.data).toEqual({ + headers: { Authorization: 'Bearer test-token', 'X-Permit-Sdk-Language': 'node' }, + params: { user: 'alice', action: 'read', resource: 'document', context: { region: 'eu' } }, + }); + // The Authorization header is buried in the body, so it is not a real header. + expect(pdp.last?.headers?.Authorization).toBeUndefined(); + }); + + // see PER-15318 (asserted above) — this also asserts the current buggy body placement + it('defaults the sdk language to `node` when omitted', async () => { + pdp.resolveWith({ allowedTenants: [] }); + + const tenants = await permit.checkAllTenants('alice', 'read', 'document'); + + expect(tenants).toEqual([]); + expect(pdp.last?.data?.headers?.['X-Permit-Sdk-Language']).toBe('node'); + // context defaults to an empty object when not provided. + expect(pdp.last?.data?.params).toEqual({ + user: 'alice', + action: 'read', + resource: 'document', + context: {}, + }); + }); + }); +}); diff --git a/src/tests/unit/utils/context.spec.ts b/src/tests/unit/utils/context.spec.ts new file mode 100644 index 0000000..ce059d5 --- /dev/null +++ b/src/tests/unit/utils/context.spec.ts @@ -0,0 +1,90 @@ +import { Context, ContextStore } from '../../../utils/context'; + +describe('ContextStore (unit)', () => { + let store: ContextStore; + + beforeEach(() => { + store = new ContextStore(); + }); + + describe('add / getDerivedContext', () => { + it('overlays the per-query context on top of the base context', () => { + store.add({ a: 1, b: 2 }); + + const derived = store.getDerivedContext({ b: 99, c: 3 }); + + // The query value for `b` wins over the base value. + expect(derived).toEqual({ a: 1, b: 99, c: 3 }); + }); + + it('does not mutate the base context when deriving', () => { + store.add({ a: 1, b: 2 }); + + store.getDerivedContext({ b: 99, c: 3 }); + + // A fresh derivation must still see the original, unmodified base. + expect(store.getDerivedContext({})).toEqual({ a: 1, b: 2 }); + }); + + it('returns a new object rather than the stored base', () => { + store.add({ a: 1 }); + + const first = store.getDerivedContext({}); + const second = store.getDerivedContext({}); + + expect(first).toEqual({ a: 1 }); + expect(first).not.toBe(second); + }); + + it('accumulates and overrides keys across successive add() calls', () => { + store.add({ a: 1 }); + store.add({ b: 2 }); + store.add({ a: 9 }); + + expect(store.getDerivedContext({})).toEqual({ a: 9, b: 2 }); + }); + + it('derives a copy of the query context when the base is empty', () => { + const query: Context = { only: 'me' }; + + const derived = store.getDerivedContext(query); + + expect(derived).toEqual({ only: 'me' }); + expect(derived).not.toBe(query); + }); + }); + + describe('registerTransform / transform', () => { + it('applies registered transforms in registration order', () => { + store.registerTransform((ctx) => ({ ...ctx, order: [...(ctx.order ?? []), 1] })); + store.registerTransform((ctx) => ({ ...ctx, order: [...(ctx.order ?? []), 2] })); + + expect(store.transform({})).toEqual({ order: [1, 2] }); + }); + + it('feeds each transform the output of the previous one', () => { + store.registerTransform((ctx) => ({ ...ctx, x: 1 })); + store.registerTransform((ctx) => ({ ...ctx, y: ctx.x + 1 })); + + expect(store.transform({})).toEqual({ x: 1, y: 2 }); + }); + + it('returns a copy of the initial context when no transforms are registered', () => { + const initial: Context = { a: 1 }; + + const result = store.transform(initial); + + expect(result).toEqual({ a: 1 }); + expect(result).not.toBe(initial); + }); + + it('does not mutate the initial context passed to transform', () => { + const initial: Context = { a: 1 }; + store.registerTransform((ctx) => ({ ...ctx, b: 2 })); + + store.transform(initial); + + expect(initial).toEqual({ a: 1 }); + }); + }); +}); diff --git a/src/tests/unit/utils/dict.spec.ts b/src/tests/unit/utils/dict.spec.ts new file mode 100644 index 0000000..b40a01b --- /dev/null +++ b/src/tests/unit/utils/dict.spec.ts @@ -0,0 +1,41 @@ +import { dictZip, isDict } from '../../../utils/dict'; + +describe('dictZip (unit)', () => { + it('zips equal-length keys and values into a record', () => { + expect(dictZip(['a', 'b', 'c'], ['1', '2', '3'])).toEqual({ a: '1', b: '2', c: '3' }); + }); + + it('returns undefined when the lists differ in length', () => { + expect(dictZip(['a', 'b'], ['1'])).toBeUndefined(); + expect(dictZip(['a'], ['1', '2'])).toBeUndefined(); + }); + + it('returns an empty record for two empty lists', () => { + expect(dictZip([], [])).toEqual({}); + }); + + it('lets the last value win when a key is repeated', () => { + expect(dictZip(['a', 'a'], ['1', '2'])).toEqual({ a: '2' }); + }); +}); + +describe('isDict (unit)', () => { + // isDict is implemented as `val !== undefined`, so it is true for every value + // except `undefined` (including null and falsy primitives). + it('is false only for undefined', () => { + expect(isDict(undefined)).toBe(false); + }); + + it('is true for null (null is not undefined)', () => { + expect(isDict(null)).toBe(true); + }); + + it('is true for objects, arrays and falsy primitives', () => { + expect(isDict({})).toBe(true); + expect(isDict({ a: 1 })).toBe(true); + expect(isDict([])).toBe(true); + expect(isDict(0)).toBe(true); + expect(isDict('')).toBe(true); + expect(isDict(false)).toBe(true); + }); +}); diff --git a/src/tests/unit/utils/http-logger.spec.ts b/src/tests/unit/utils/http-logger.spec.ts new file mode 100644 index 0000000..e6edc0c --- /dev/null +++ b/src/tests/unit/utils/http-logger.spec.ts @@ -0,0 +1,80 @@ +import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +import { AxiosLoggingInterceptor } from '../../../utils/http-logger'; +import { fakeLogger, synthAxiosError } from '../../helpers/mock-api'; + +type RequestHandler = { + fulfilled: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig; + rejected: (error: unknown) => Promise; +}; + +function requestHandler(instance: ReturnType): RequestHandler { + return (instance.interceptors.request as any).handlers[0]; +} + +describe('AxiosLoggingInterceptor (unit)', () => { + it('logs the outgoing request and the response at debug level', async () => { + const instance = axios.create(); + instance.defaults.adapter = async ( + config: InternalAxiosRequestConfig, + ): Promise => ({ + data: { ok: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + const logger = fakeLogger(); + AxiosLoggingInterceptor.setupInterceptor(instance, logger as any); + + await instance.get('http://example.test/foo'); + + expect(logger.debug).toHaveBeenCalledWith('Sending HTTP request: GET http://example.test/foo'); + expect(logger.debug).toHaveBeenCalledWith( + 'Received HTTP response: GET http://example.test/foo, status: 200', + ); + }); + + it('initializes request.headers when the outgoing config has none', () => { + const instance = axios.create(); + const logger = fakeLogger(); + AxiosLoggingInterceptor.setupInterceptor(instance, logger as any); + + const config = { method: 'get', url: '/x' } as unknown as InternalAxiosRequestConfig; + const result = requestHandler(instance).fulfilled(config); + + expect(result.headers).toEqual({}); + expect(logger.debug).toHaveBeenCalledWith('Sending HTTP request: GET /x'); + }); + + // The request-interceptor's `rejected` handler only fires when an earlier + // interceptor in the chain rejects. With a single interceptor installed there + // is no real request flow that reaches it, so we invoke it directly. + it('propagates request errors through the rejection handler', async () => { + const instance = axios.create(); + const logger = fakeLogger(); + AxiosLoggingInterceptor.setupInterceptor(instance, logger as any); + + const boom = new Error('request boom'); + + await expect(requestHandler(instance).rejected(boom)).rejects.toBe(boom); + }); + + it('propagates response errors through the rejection handler on a failed request', async () => { + const instance = axios.create(); + const error = synthAxiosError(500, { message: 'boom' }); + instance.defaults.adapter = async () => { + throw error; + }; + const logger = fakeLogger(); + AxiosLoggingInterceptor.setupInterceptor(instance, logger as any); + + await expect(instance.get('http://example.test/foo')).rejects.toBe(error); + // The request interceptor still logged the outgoing request before the adapter rejected. + expect(logger.debug).toHaveBeenCalledWith('Sending HTTP request: GET http://example.test/foo'); + // The response-error branch only re-rejects; it does not emit a response log. + expect(logger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Received HTTP response'), + ); + }); +}); diff --git a/src/tests/unit/utils/regex.spec.ts b/src/tests/unit/utils/regex.spec.ts new file mode 100644 index 0000000..e0d29c4 --- /dev/null +++ b/src/tests/unit/utils/regex.spec.ts @@ -0,0 +1,76 @@ +import { escapeRegex, matchAll } from '../../../utils/regex'; + +describe('escapeRegex (unit)', () => { + it('escapes every regex metacharacter', () => { + expect(escapeRegex('a.b*c')).toBe('a\\.b\\*c'); + expect(escapeRegex('1+1')).toBe('1\\+1'); + expect(escapeRegex('(group)')).toBe('\\(group\\)'); + }); + + it('leaves non-special characters untouched', () => { + expect(escapeRegex('abc123')).toBe('abc123'); + }); + + it('produces a pattern that matches the original string literally', () => { + const literal = 'a.b(c)+'; + const re = new RegExp(`^${escapeRegex(literal)}$`); + + expect(re.test(literal)).toBe(true); + // The unescaped `.` would otherwise match any character here. + expect(re.test('axb(c)+')).toBe(false); + }); +}); + +describe('matchAll (unit)', () => { + it('reports start, end, length and the matched group for every match', () => { + const result = matchAll(/\d+/, 'a12b345'); + + expect(result).toEqual([ + { start: 1, end: 2, length: 2, groups: ['12'] }, + { start: 4, end: 6, length: 3, groups: ['345'] }, + ]); + }); + + it('includes capturing groups after the full match', () => { + const result = matchAll(/(\w)(\d)/, 'a1 b2'); + + expect(result).toEqual([ + { start: 0, end: 1, length: 2, groups: ['a1', 'a', '1'] }, + { start: 3, end: 4, length: 2, groups: ['b2', 'b', '2'] }, + ]); + }); + + it('auto-globalizes a non-global regex (avoiding an infinite loop)', () => { + const result = matchAll(/a/, 'aaa'); + + expect(result).toHaveLength(3); + expect(result.map((m) => m.start)).toEqual([0, 1, 2]); + }); + + it('preserves the ignoreCase flag when globalizing', () => { + const source = /foo/i; + + const result = matchAll(source, 'foo FOO Foo'); + + expect(result).toHaveLength(3); + // The caller's regex is not mutated into a global one. + expect(source.global).toBe(false); + }); + + it('preserves the multiline flag when globalizing', () => { + const result = matchAll(/^a/im, 'A\nbb\na'); + + // With m+i, `^a` anchors to the start of each line case-insensitively. + expect(result.map((m) => m.start)).toEqual([0, 5]); + }); + + it('passes an already-global regex through unchanged', () => { + const result = matchAll(/\d/g, '1a2b3'); + + expect(result.map((m) => m.groups[0])).toEqual(['1', '2', '3']); + }); + + it('returns an empty array when there is no match', () => { + expect(matchAll(/z/, 'abc')).toEqual([]); + }); +});