diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index 9848cccb15..37a4d98139 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@forestadmin/agent-client": "1.10.1", + "@forestadmin/datasource-toolkit": "1.53.1", "@forestadmin/forestadmin-client": "1.40.4", "@koa/bodyparser": "^6.1.0", "jsonwebtoken": "^9.0.3", diff --git a/packages/agent-bff/src/action/action-execute-mapper.ts b/packages/agent-bff/src/action/action-execute-mapper.ts new file mode 100644 index 0000000000..a0b7c17f45 --- /dev/null +++ b/packages/agent-bff/src/action/action-execute-mapper.ts @@ -0,0 +1,84 @@ +export interface ActionExecuteSuccessBody { + type: 'success'; + message: string | null; + invalidated: string[]; + html: string | null; +} + +export interface ActionExecuteWebhookBody { + type: 'webhook'; + url: unknown; + method: unknown; + headers: unknown; + body: unknown; +} + +export interface ActionExecuteRedirectBody { + type: 'redirect'; + path: unknown; +} + +export interface ActionExecuteUnsupportedBody { + error: { type: 'unsupported_action_result'; status: 501 }; +} + +export type ActionExecuteMappedBody = + | ActionExecuteSuccessBody + | ActionExecuteWebhookBody + | ActionExecuteRedirectBody + | ActionExecuteUnsupportedBody; + +export interface ActionExecuteMapped { + status: number; + body: ActionExecuteMappedBody; +} + +// Normalizes the agent's 200 execute payload into the flat BFF wrapper. The native ActionResult +// union never reaches the BFF (agent-client `execute()` is typed `{ success, html? }`), so we +// discriminate on the agent HTTP payload shape. A File result streams a binary with no JSON marker, +// so any unrecognized 200 body falls through to a structured 501 rather than being mislabelled. +export function mapActionExecuteResult(raw: unknown): ActionExecuteMapped { + const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; + + // Each branch validates the value shape, not just key presence: a malformed payload + // (`{ webhook: null }`, `{ redirectTo: {} }`, `{ success: {} }`) must fall through to the 501 + // path rather than be surfaced as a 200 with null fields the client cannot tell from a real one. + if (typeof body.webhook === 'object' && body.webhook !== null) { + const hook = body.webhook as Record; + + return { + status: 200, + body: { + type: 'webhook', + url: hook.url, + method: hook.method, + headers: hook.headers, + body: hook.body, + }, + }; + } + + if (typeof body.redirectTo === 'string') { + return { status: 200, body: { type: 'redirect', path: body.redirectTo } }; + } + + const refresh = typeof body.refresh === 'object' && body.refresh !== null ? body.refresh : null; + + // A Success carries a string message and/or a refresh object; the agent always sends the refresh, + // so a bare refresh (message absent) still normalizes to a success with a null message. + if (typeof body.success === 'string' || refresh !== null) { + const relationships = (refresh as { relationships?: unknown } | null)?.relationships; + + return { + status: 200, + body: { + type: 'success', + message: typeof body.success === 'string' ? body.success : null, + invalidated: Array.isArray(relationships) ? (relationships as string[]) : [], + html: typeof body.html === 'string' ? body.html : null, + }, + }; + } + + return { status: 501, body: { error: { type: 'unsupported_action_result', status: 501 } } }; +} diff --git a/packages/agent-bff/src/action/action-form-mapper.ts b/packages/agent-bff/src/action/action-form-mapper.ts new file mode 100644 index 0000000000..56fe55e018 --- /dev/null +++ b/packages/agent-bff/src/action/action-form-mapper.ts @@ -0,0 +1,57 @@ +import type { ActionForm } from './agent-action-client'; +import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client'; + +const ENUM_TYPE = 'Enum'; + +export interface ActionFormFieldResponse { + name: string; + type: string; + value: unknown; + isRequired: boolean; + enumValues?: string[] | null; +} + +export interface ActionFormResponse { + fields: ActionFormFieldResponse[]; + canExecute: boolean; + requiredFields: string[]; + skippedFields: string[]; + layout: ForestServerActionFormLayoutElement[]; +} + +// Mirrors the MCP getActionForm tool, adding `layout`. A required field is "missing a value" only +// when its resolved value is null/undefined; an explicit empty string or 0 counts as present. +export function mapActionForm( + action: ActionForm, + skippedFields: string[], + layout: ForestServerActionFormLayoutElement[], +): ActionFormResponse { + const fields = action.getFields(); + + const requiredFields = fields + .filter(field => field.isRequired()) + .filter(field => field.getValue() === undefined || field.getValue() === null) + .map(field => field.getName()); + + return { + fields: fields.map(field => { + const base: ActionFormFieldResponse = { + name: field.getName(), + type: field.getType(), + value: field.getValue(), + isRequired: field.isRequired() ?? false, + }; + + // enumValues is emitted only for Enum fields, matching the MCP tool. + if (field.getType() === ENUM_TYPE) { + return { ...base, enumValues: action.getEnumField(field.getName()).getOptions() ?? null }; + } + + return base; + }), + canExecute: requiredFields.length === 0, + requiredFields, + skippedFields, + layout, + }; +} diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts new file mode 100644 index 0000000000..0a9f90650b --- /dev/null +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -0,0 +1,178 @@ +import type { AgentActionClient, AgentActionClientOptions } from './agent-action-client'; +import type { Logger } from '../ports/logger-port'; +import type ReadModelStore from '../read-model/read-model-store'; +import type { Context, Middleware } from 'koa'; + +import { + ActionFormValidationError, + ActionRequiresApprovalError, + UnknownActionFieldError, +} from '@forestadmin/agent-client'; + +import { mapActionExecuteResult } from './action-execute-mapper'; +import { mapActionForm } from './action-form-mapper'; +import defaultCreateAgentActionClient, { extractRawLayout } from './agent-action-client'; +import { mapAgentError } from '../http/agent-error-mapper'; +import { + callAgent, + decodeSegment, + requireAgentToken, + resolveReadModel, +} from '../http/agent-route-helpers'; +import { actionRequiresApproval, invalidRequest, unknownAction } from '../http/bff-local-errors'; + +const ACTION_ROUTE = /^\/agent\/v1\/([^/]+)\/actions\/([^/]+)\/(form|execute)$/; + +interface ActionRequestBody { + recordIds?: unknown; + values?: unknown; +} + +// recordIds are opaque BFF ids; only presence and array-ness are checked. An empty array is allowed +// on purpose (global/bulk actions have no selected records); the spec AC only covers a missing +// recordIds, so `[]` is an explicit BFF choice, not a spec requirement. +// Ids are coerced to strings so a valid `0` (numeric primary key) survives agent-client's downstream +// `.filter(Boolean)`, which would otherwise drop it and load the form as if no record were selected. +function parseRecordIds(raw: unknown): string[] { + if (!Array.isArray(raw)) { + throw invalidRequest('recordIds is required and must be an array'); + } + + return raw.map(id => String(id)); +} + +function parseValues(raw: unknown): Record { + if (raw === undefined) return {}; + + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw invalidRequest('values must be an object'); + } + + return raw as Record; +} + +export interface ActionRoutesMiddlewareOptions { + store: ReadModelStore; + agentUrl: string; + logger: Logger; + createClient?: (options: AgentActionClientOptions) => AgentActionClient; +} + +async function handleForm( + ctx: Context, + client: AgentActionClient, + collection: string, + actionName: string, + recordIds: string[], + values: Record, + logger: Logger, +): Promise { + // Each agent-hitting call is wrapped on its own; getFields/extractRawLayout/mapping stay outside + // callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and + // layout are read AFTER tryToSetFields because a change hook rebuilds them in place. + const action = await callAgent(() => client.loadAction(collection, actionName, recordIds), logger); + const skippedFields = await callAgent(() => action.tryToSetFields(values), logger); + + ctx.status = 200; + ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action)); +} + +async function handleExecute( + ctx: Context, + client: AgentActionClient, + collection: string, + actionName: string, + recordIds: string[], + values: Record, + logger: Logger, +): Promise { + const action = await callAgent(() => client.loadAction(collection, actionName, recordIds), logger); + + // setFields is strict: an unknown submitted field is a client error (400), not a 500. A transport + // failure from the change-hook it triggers is a genuine agent error, so it goes to the mapper. + try { + await action.setFields(values); + } catch (error) { + if (error instanceof UnknownActionFieldError) throw invalidRequest(error.message); + throw mapAgentError(error, { logger }); + } + + // execute() cannot go through the generic callAgent: agent-client turns the native action Error + // (HTTP 400) into ActionFormValidationError, a non-AgentHttpError the mapper would mislabel as a + // transport 502. So the semantic outcomes are caught here, everything else falls to the mapper. + let raw: unknown; + + try { + raw = await action.execute(); + } catch (error) { + if (error instanceof ActionRequiresApprovalError) { + throw actionRequiresApproval( + error.message, + error.roleIdsAllowedToApprove !== undefined + ? { roleIdsAllowedToApprove: error.roleIdsAllowedToApprove } + : undefined, + ); + } + + if (error instanceof ActionFormValidationError) { + ctx.status = 400; + ctx.body = { type: 'error', status: 400, message: error.message, html: error.html ?? null }; + + return; + } + + throw mapAgentError(error, { logger }); + } + + const { status, body } = mapActionExecuteResult(raw); + ctx.status = status; + ctx.body = body; +} + +export default function createActionRoutesMiddleware({ + store, + agentUrl, + logger, + createClient = defaultCreateAgentActionClient, +}: ActionRoutesMiddlewareOptions): Middleware { + return async function actionRoutesMiddleware(ctx, next) { + const match = ACTION_ROUTE.exec(ctx.path); + + if (!match || ctx.method !== 'POST') { + await next(); + + return; + } + + const collection = decodeSegment(match[1], 'collection name'); + const actionName = decodeSegment(match[2], 'action name'); + const verb = match[3]; + const token = requireAgentToken(ctx); + const readModel = await resolveReadModel(store); + + // The read-model's action map IS the allow-list, so an absent action cannot be told from a + // known-but-disallowed one — every non-exposed action maps to 404 here; `action_not_allowed` + // (403) has no local trigger, mirroring `collection_not_allowed`/`relation_not_allowed`. The + // URL identity is resolved before the body, so a bad action 404s before its payload is read. + // TODO(PRD-673): distinguish disallowed from unknown when a separate exposure source exists. + if (!readModel.isActionAllowed(collection, actionName)) { + throw unknownAction(`Unknown action: ${collection}.${actionName}`); + } + + const body = (ctx.request.body ?? {}) as ActionRequestBody; + const recordIds = parseRecordIds(body.recordIds); + const values = parseValues(body.values); + + const client = createClient({ + agentUrl, + token, + actionEndpoints: readModel.getActionEndpoints(), + }); + + if (verb === 'execute') { + await handleExecute(ctx, client, collection, actionName, recordIds, values, logger); + } else { + await handleForm(ctx, client, collection, actionName, recordIds, values, logger); + } + }; +} diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts new file mode 100644 index 0000000000..e746e6dae4 --- /dev/null +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -0,0 +1,65 @@ +import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; +import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client'; + +import { createRemoteAgentClient } from '@forestadmin/agent-client'; + +export interface ActionFormField { + getName(): string; + getType(): string; + getValue(): unknown; + isRequired(): boolean | undefined; +} + +// Structural subset of agent-client's `Action` the form endpoint consumes. Kept local so the BFF +// does not depend on agent-client exporting the concrete `Action` type (see extractRawLayout). +export interface ActionForm { + tryToSetFields(values: Record): Promise; + getFields(): ActionFormField[]; + getEnumField(name: string): { getOptions(): string[] | undefined }; + getLayout(): unknown; +} + +// The form and execute endpoints load the same agent-client `Action` object; execute adds the +// strict setFields + execute members on top of the form ones. +export interface Action extends ActionForm { + setFields(values: Record): Promise; + execute(): Promise; +} + +export interface AgentActionClient { + loadAction(collection: string, action: string, recordIds: string[]): Promise; +} + +export interface AgentActionClientOptions { + agentUrl: string; + token: string; + actionEndpoints: ActionEndpointsByCollection; +} + +// The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place. +// agent-client's `Action.getLayout()` only returns an `ActionLayoutRoot` wrapper whose element array +// lives in a protected field. The rollback contract forbids agent-client changes, so we read it +// through a cast rather than adding a public accessor. A layout-shape test guards against drift. +export function extractRawLayout(action: ActionForm): ForestServerActionFormLayoutElement[] { + const root = action.getLayout() as { layout?: ForestServerActionFormLayoutElement[] }; + + return root.layout ?? []; +} + +/** + * Thin action-form client bound to a request's agent token. Reuses agent-client's stateful form + * loading (`/hooks/load` + `/hooks/change`, Ruby 404 fallback, dependent-field re-evaluation) rather + * than reimplementing it. The endpoint map from the read-model is the action allow-list. + */ +export default function createAgentActionClient({ + agentUrl, + token, + actionEndpoints, +}: AgentActionClientOptions): AgentActionClient { + const client = createRemoteAgentClient({ url: agentUrl, token, actionEndpoints }); + + return { + loadAction: (collection, action, recordIds) => + client.collection(collection).action(action, { recordIds }), + }; +} diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index f52258c616..ad5e807263 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -4,6 +4,7 @@ import type { Middleware } from 'koa'; import { bodyParser } from '@koa/bodyparser'; +import createActionRoutesMiddleware from './action/action-routes-middleware'; import createConsoleLogger from './adapters/console-logger'; import createAgentStubMiddleware from './agent/agent-stub'; import createApiKeyAuthenticator from './api-key/api-key-authenticator'; @@ -154,13 +155,15 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | return createApiKeyMiddleware({ authenticator, logger }); } -function buildDataRoutesMiddleware(config: BFFConfig, logger: Logger): Middleware { +// Data and action routes share one read-model store (a single cache, a single schema fetch). The +// data middleware falls through to the action middleware on a non-data path. +function buildAgentRouteMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { const apiKeyConfig = resolveApiKeyConfig(config); if (!apiKeyConfig || !config.agentUrl) { logger('Warn', 'Data endpoints disabled: AGENT_URL or read-model configuration is missing'); - return createAgentStubMiddleware(); + return [createAgentStubMiddleware()]; } const { store } = createReadModel({ @@ -168,8 +171,12 @@ function buildDataRoutesMiddleware(config: BFFConfig, logger: Logger): Middlewar envSecret: apiKeyConfig.forestEnvSecret, logger, }); + const { agentUrl } = config; - return createDataRoutesMiddleware({ store, agentUrl: config.agentUrl, logger }); + return [ + createDataRoutesMiddleware({ store, agentUrl, logger }), + createActionRoutesMiddleware({ store, agentUrl, logger }), + ]; } function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { @@ -188,7 +195,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] apiKeyStep, createPerKeyOriginMiddleware(), createTimezoneMiddleware({ defaultTimezone }), - buildDataRoutesMiddleware(config, logger), + ...buildAgentRouteMiddlewares(config, logger), ]; return chain.map(agentScoped); diff --git a/packages/agent-bff/src/data/data-routes-middleware.ts b/packages/agent-bff/src/data/data-routes-middleware.ts index 9ce46fb8fc..974928faca 100644 --- a/packages/agent-bff/src/data/data-routes-middleware.ts +++ b/packages/agent-bff/src/data/data-routes-middleware.ts @@ -23,15 +23,13 @@ import { parseRelationListRequest, } from './agent-query'; import { mapCountResponse, mapListResponse } from './response-mappers'; -import { mapAgentError } from '../http/agent-error-mapper'; -import { unauthorized } from '../http/bff-http-error'; import { - invalidRequest, - schemaUnavailable, - unknownCollection, - unknownRelation, -} from '../http/bff-local-errors'; -import SchemaUnavailableError from '../read-model/errors'; + callAgent, + decodeSegment, + requireAgentToken, + resolveReadModel, +} from '../http/agent-route-helpers'; +import { unknownCollection, unknownRelation } from '../http/bff-local-errors'; import assertNoRelationFieldPaths from '../validation/relation-field-guard'; const DATA_ROUTE = /^\/agent\/v1\/([^/]+)\/(list|count)$/; @@ -62,33 +60,6 @@ interface RequestHandlerDeps { type ListHandlerDeps = RequestHandlerDeps & { primaryKeys: PrimaryKeyField[] }; -function decodeSegment(raw: string, label: string): string { - try { - return decodeURIComponent(raw); - } catch { - throw invalidRequest(`Malformed ${label} in path`); - } -} - -async function resolveReadModel(store: ReadModelStore): Promise { - try { - return await store.getReadModel(); - } catch (error) { - if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); - throw error; - } -} - -// Only the agent call is wrapped: guard, query-building and mapping errors are BFF-origin and must -// keep their own type, not be recategorized as agent errors. -async function callAgent(fn: () => Promise, logger: Logger): Promise { - try { - return await fn(); - } catch (error) { - throw mapAgentError(error, { logger }); - } -} - async function handleList(ctx: Context, body: ListRequestBody, deps: ListHandlerDeps) { assertNoRelationFieldPaths(collectListFieldPaths(body)); @@ -213,13 +184,7 @@ export default function createDataRoutesMiddleware({ } const collection = decodeSegment(match[1], 'collection name'); - - // The agent token is minted only on the API-key path; the OAuth path sets a principal but no - // agent token yet. Fail closed instead of calling the agent with `Bearer undefined`. - // TODO(PRD-637): mint an agent token from the OAuth principal so data routes work in OAuth mode. - const token = ctx.state.agentToken as string | undefined; - if (!token) throw unauthorized('No agent credentials for this request'); - + const token = requireAgentToken(ctx); const readModel = await resolveReadModel(store); if (!readModel.isCollectionAllowed(collection)) { diff --git a/packages/agent-bff/src/http/agent-route-helpers.ts b/packages/agent-bff/src/http/agent-route-helpers.ts new file mode 100644 index 0000000000..bd371da113 --- /dev/null +++ b/packages/agent-bff/src/http/agent-route-helpers.ts @@ -0,0 +1,46 @@ +import type { Logger } from '../ports/logger-port'; +import type ReadModel from '../read-model/read-model'; +import type ReadModelStore from '../read-model/read-model-store'; +import type { Context } from 'koa'; + +import { mapAgentError } from './agent-error-mapper'; +import { unauthorized } from './bff-http-error'; +import { invalidRequest, schemaUnavailable } from './bff-local-errors'; +import SchemaUnavailableError from '../read-model/errors'; + +export function decodeSegment(raw: string, label: string): string { + try { + return decodeURIComponent(raw); + } catch { + throw invalidRequest(`Malformed ${label} in path`); + } +} + +export async function resolveReadModel(store: ReadModelStore): Promise { + try { + return await store.getReadModel(); + } catch (error) { + if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); + throw error; + } +} + +// The agent token is minted only on the API-key path; the OAuth path sets a principal but no agent +// token yet. Fail closed instead of calling the agent with `Bearer undefined`. +// TODO(PRD-637): mint an agent token from the OAuth principal so agent routes work in OAuth mode. +export function requireAgentToken(ctx: Context): string { + const token = ctx.state.agentToken as string | undefined; + if (!token) throw unauthorized('No agent credentials for this request'); + + return token; +} + +// Only the agent call is wrapped: guard, query-building and mapping errors are BFF-origin and must +// keep their own type, not be recategorized as agent errors. +export async function callAgent(fn: () => Promise, logger: Logger): Promise { + try { + return await fn(); + } catch (error) { + throw mapAgentError(error, { logger }); + } +} diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index e4d37d95df..edfae8bb43 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -39,3 +39,10 @@ export function schemaUnavailable(message = 'The agent schema is unavailable'): export function unsupportedActionResult(message = 'Unsupported action result'): BffHttpError { return new BffHttpError(501, 'unsupported_action_result', message); } + +export function actionRequiresApproval( + message = 'This action requires an approval before it can run', + details?: unknown, +): BffHttpError { + return new BffHttpError(403, 'action_requires_approval', message, details); +} diff --git a/packages/agent-bff/src/read-model/capabilities-cache.ts b/packages/agent-bff/src/read-model/capabilities-cache.ts index 59842ccef3..a317be7fa1 100644 --- a/packages/agent-bff/src/read-model/capabilities-cache.ts +++ b/packages/agent-bff/src/read-model/capabilities-cache.ts @@ -1,7 +1,7 @@ import { ONE_DAY_MS } from './schema-cache'; export interface CapabilitiesResult { - fields: { name: string; type: string; operators: string[] }[]; + fields: { name: string; type: string; operators?: string[] }[]; } export type CapabilitiesFetcher = (collection: string) => Promise; diff --git a/packages/agent-bff/src/validation/capabilities-validator.ts b/packages/agent-bff/src/validation/capabilities-validator.ts new file mode 100644 index 0000000000..b66c5b5a65 --- /dev/null +++ b/packages/agent-bff/src/validation/capabilities-validator.ts @@ -0,0 +1,136 @@ +import type { BffHttpError } from '../http/bff-http-error'; +import type { CapabilitiesResult } from '../read-model/capabilities-cache'; + +import { mappingError } from '../http/bff-local-errors'; +import { normalizeOperator } from './operator-normalizer'; +import { fieldNotFilterable, invalidFilterOperator, unknownField } from './validation-errors'; + +export interface ValidateParams { + filter?: unknown; + sortFields?: string[]; + projectionFields?: string[]; +} + +interface FilterLeaf { + field: string; + operator?: string; +} + +function isBranch(node: unknown): node is { conditions: unknown[] } { + return ( + typeof node === 'object' && + node !== null && + Array.isArray((node as { conditions?: unknown }).conditions) + ); +} + +function isLeaf(node: unknown): node is FilterLeaf { + return ( + typeof node === 'object' && + node !== null && + typeof (node as { field?: unknown }).field === 'string' + ); +} + +function collectLeaves(node: unknown, acc: FilterLeaf[]): void { + if (isBranch(node)) { + node.conditions.forEach(condition => collectLeaves(condition, acc)); + } else if (isLeaf(node)) { + const { operator } = node as { operator?: unknown }; + acc.push({ field: node.field, operator: typeof operator === 'string' ? operator : undefined }); + } +} + +function indexOperators(capabilities: CapabilitiesResult): Map { + const index = new Map(); + capabilities.fields.forEach(field => index.set(field.name, field.operators ?? [])); + + return index; +} + +function normalizeFieldOperators(field: string, operators: string[]): string[] { + return operators.map(operator => { + const normalized = normalizeOperator(operator); + + if (!normalized) { + throw mappingError( + `Capabilities operator "${operator}" on field "${field}" has no canonical mapping`, + ); + } + + return normalized; + }); +} + +function validateFilter(filter: unknown, index: Map): BffHttpError[] { + const leaves: FilterLeaf[] = []; + collectLeaves(filter, leaves); + + const errors: BffHttpError[] = []; + + for (const leaf of leaves) { + const operators = index.get(leaf.field); + + if (operators === undefined) { + errors.push(unknownField(leaf.field)); + } else if (operators.length === 0) { + errors.push(fieldNotFilterable(leaf.field)); + } else { + const normalized = normalizeFieldOperators(leaf.field, operators); + + if (leaf.operator === undefined || !normalized.includes(leaf.operator)) { + errors.push(invalidFilterOperator(leaf.field, normalized)); + } + } + } + + return errors; +} + +function validateExistence(fields: string[], index: Map): BffHttpError[] { + return fields.filter(field => !index.has(field)).map(unknownField); +} + +function dedupe(errors: BffHttpError[]): BffHttpError[] { + const seen = new Set(); + const result: BffHttpError[] = []; + + for (const error of errors) { + const key = `${error.type}:${(error.details as { field?: string }).field}`; + + if (!seen.has(key)) { + seen.add(key); + result.push(error); + } + } + + return result; +} + +/** + * Validates a request's filter, sort, and projection fields against the target collection's + * capabilities. Returns every offending field as a structured error (empty = valid); the caller + * surfaces the whole list or just the first. Throws a 500 mapping_error when a capabilities + * operator has no canonical mapping (a version skew between agent and BFF). + */ +export function validateAgainstCapabilities( + params: ValidateParams, + capabilities: CapabilitiesResult, +): BffHttpError[] { + const index = indexOperators(capabilities); + + return dedupe([ + ...validateFilter(params.filter, index), + ...validateExistence(params.sortFields ?? [], index), + ...validateExistence(params.projectionFields ?? [], index), + ]); +} + +export function assertValidAgainstCapabilities( + params: ValidateParams, + capabilities: CapabilitiesResult, +): void { + const [first] = validateAgainstCapabilities(params, capabilities); + + if (first) throw first; +} diff --git a/packages/agent-bff/src/validation/operator-normalizer.ts b/packages/agent-bff/src/validation/operator-normalizer.ts new file mode 100644 index 0000000000..fe0319a661 --- /dev/null +++ b/packages/agent-bff/src/validation/operator-normalizer.ts @@ -0,0 +1,28 @@ +import type { Operator } from '@forestadmin/datasource-toolkit'; + +import { allOperators } from '@forestadmin/datasource-toolkit'; + +/** + * Mirrors the agent's capabilities serialization (`packages/agent/src/routes/capabilities.ts`), + * which converts each PascalCase operator to snake_case before returning it. Kept identical so the + * inverse map below round-trips every operator the agent can emit. + */ +export function toSnakeCaseOperator(operator: string): string { + return operator + .split(/\.?(?=[A-Z])/) + .join('_') + .toLowerCase(); +} + +const SNAKE_TO_PASCAL: Record = Object.fromEntries( + allOperators.map(operator => [toSnakeCaseOperator(operator), operator]), +); + +/** + * Maps an agent snake_case capabilities operator to the canonical PascalCase operator from + * datasource-toolkit `allOperators`. Returns undefined when no canonical operator matches, which + * only happens when the agent runs a newer operator set than this package (a version skew). + */ +export function normalizeOperator(snakeCaseOperator: string): Operator | undefined { + return SNAKE_TO_PASCAL[snakeCaseOperator]; +} diff --git a/packages/agent-bff/src/validation/validation-errors.ts b/packages/agent-bff/src/validation/validation-errors.ts new file mode 100644 index 0000000000..b8ac8889a6 --- /dev/null +++ b/packages/agent-bff/src/validation/validation-errors.ts @@ -0,0 +1,20 @@ +import { BffHttpError } from '../http/bff-http-error'; + +export function unknownField(field: string): BffHttpError { + return new BffHttpError(422, 'unknown_field', `Unknown field: ${field}`, { field }); +} + +export function fieldNotFilterable(field: string): BffHttpError { + return new BffHttpError(422, 'field_not_filterable', `Field is not filterable: ${field}`, { + field, + }); +} + +export function invalidFilterOperator(field: string, validOperators: string[]): BffHttpError { + return new BffHttpError( + 400, + 'invalid_filter_operator', + `Unsupported operator for field: ${field}`, + { field, validOperators }, + ); +} diff --git a/packages/agent-bff/test/action/action-execute-mapper.test.ts b/packages/agent-bff/test/action/action-execute-mapper.test.ts new file mode 100644 index 0000000000..2dc26b8ad0 --- /dev/null +++ b/packages/agent-bff/test/action/action-execute-mapper.test.ts @@ -0,0 +1,79 @@ +import { mapActionExecuteResult } from '../../src/action/action-execute-mapper'; + +describe('mapActionExecuteResult', () => { + it('maps a Success payload, serializing refresh.relationships into invalidated', () => { + expect( + mapActionExecuteResult({ + success: 'Done', + html: 'ok', + refresh: { relationships: ['orders', 'items'] }, + }), + ).toEqual({ + status: 200, + body: { type: 'success', message: 'Done', invalidated: ['orders', 'items'], html: 'ok' }, + }); + }); + + it('defaults message and html to null and invalidated to [] when absent', () => { + expect(mapActionExecuteResult({ success: undefined, refresh: { relationships: [] } })).toEqual({ + status: 200, + body: { type: 'success', message: null, invalidated: [], html: null }, + }); + }); + + it('treats a bare refresh payload as a Success', () => { + expect(mapActionExecuteResult({ refresh: { relationships: ['orders'] } })).toEqual({ + status: 200, + body: { type: 'success', message: null, invalidated: ['orders'], html: null }, + }); + }); + + it('maps a Webhook payload verbatim', () => { + expect( + mapActionExecuteResult({ + webhook: { url: 'https://x.test', method: 'POST', headers: { a: '1' }, body: { b: 2 } }, + }), + ).toEqual({ + status: 200, + body: { + type: 'webhook', + url: 'https://x.test', + method: 'POST', + headers: { a: '1' }, + body: { b: 2 }, + }, + }); + }); + + it('maps a Redirect payload to the path', () => { + expect(mapActionExecuteResult({ redirectTo: '/orders/1' })).toEqual({ + status: 200, + body: { type: 'redirect', path: '/orders/1' }, + }); + }); + + it('falls through to 501 for an unrecognized (File) payload', () => { + expect(mapActionExecuteResult({})).toEqual({ + status: 501, + body: { error: { type: 'unsupported_action_result', status: 501 } }, + }); + }); + + it('falls through to 501 for a non-object payload', () => { + expect(mapActionExecuteResult(null)).toEqual({ + status: 501, + body: { error: { type: 'unsupported_action_result', status: 501 } }, + }); + }); + + it.each([ + ['a null webhook', { webhook: null }], + ['a non-string redirectTo', { redirectTo: {} }], + ['a non-string success with no refresh', { success: {} }], + ])('falls through to 501 for a malformed payload: %s', (_label, payload) => { + expect(mapActionExecuteResult(payload)).toEqual({ + status: 501, + body: { error: { type: 'unsupported_action_result', status: 501 } }, + }); + }); +}); diff --git a/packages/agent-bff/test/action/action-form-mapper.test.ts b/packages/agent-bff/test/action/action-form-mapper.test.ts new file mode 100644 index 0000000000..6d3361581d --- /dev/null +++ b/packages/agent-bff/test/action/action-form-mapper.test.ts @@ -0,0 +1,98 @@ +import type { ActionForm, ActionFormField } from '../../src/action/agent-action-client'; + +import { mapActionForm } from '../../src/action/action-form-mapper'; + +interface FakeField { + name: string; + type: string; + value: unknown; + isRequired: boolean; + enumValues?: string[]; +} + +function fieldStub(f: FakeField): ActionFormField { + return { + getName: () => f.name, + getType: () => f.type, + getValue: () => f.value, + isRequired: () => f.isRequired, + }; +} + +function actionWith(fields: FakeField[]): ActionForm { + return { + tryToSetFields: async () => [], + getFields: () => fields.map(fieldStub), + getEnumField: (name: string) => ({ + getOptions: () => fields.find(f => f.name === name)?.enumValues, + }), + getLayout: () => ({ layout: [] }), + }; +} + +describe('mapActionForm', () => { + it('maps each field to name, type, value and isRequired', () => { + const action = actionWith([{ name: 'reason', type: 'String', value: 'x', isRequired: true }]); + + const result = mapActionForm(action, [], []); + + expect(result.fields).toEqual([ + { name: 'reason', type: 'String', value: 'x', isRequired: true }, + ]); + }); + + it('emits enumValues only for Enum fields', () => { + const action = actionWith([ + { name: 'reason', type: 'String', value: null, isRequired: false }, + { name: 'status', type: 'Enum', value: null, isRequired: false, enumValues: ['a', 'b'] }, + ]); + + const result = mapActionForm(action, [], []); + + expect(result.fields[0]).not.toHaveProperty('enumValues'); + expect(result.fields[1]).toMatchObject({ name: 'status', enumValues: ['a', 'b'] }); + }); + + it('sets enumValues to null when an Enum field has no options', () => { + const action = actionWith([{ name: 'status', type: 'Enum', value: null, isRequired: false }]); + + const result = mapActionForm(action, [], []); + + expect(result.fields[0].enumValues).toBeNull(); + }); + + it('lists required fields whose resolved value is null or undefined and sets canExecute false', () => { + const action = actionWith([ + { name: 'a', type: 'String', value: null, isRequired: true }, + { name: 'b', type: 'String', value: undefined, isRequired: true }, + { name: 'c', type: 'String', value: 'set', isRequired: true }, + ]); + + const result = mapActionForm(action, [], []); + + expect(result.requiredFields).toEqual(['a', 'b']); + expect(result.canExecute).toBe(false); + }); + + it('treats an explicit empty string or 0 as present so canExecute is true', () => { + const action = actionWith([ + { name: 'a', type: 'String', value: '', isRequired: true }, + { name: 'b', type: 'Number', value: 0, isRequired: true }, + ]); + + const result = mapActionForm(action, [], []); + + expect(result.requiredFields).toEqual([]); + expect(result.canExecute).toBe(true); + }); + + it('passes skippedFields and layout through unchanged', () => { + const layout = [{ component: 'page', elements: [] }] as never; + const action = actionWith([]); + + const result = mapActionForm(action, ['ghost'], layout); + + expect(result.skippedFields).toEqual(['ghost']); + expect(result.layout).toBe(layout); + }); +}); diff --git a/packages/agent-bff/test/action/action-routes-middleware.test.ts b/packages/agent-bff/test/action/action-routes-middleware.test.ts new file mode 100644 index 0000000000..e4e53ffc01 --- /dev/null +++ b/packages/agent-bff/test/action/action-routes-middleware.test.ts @@ -0,0 +1,596 @@ +import type { Action, AgentActionClient } from '../../src/action/agent-action-client'; +import type { Logger } from '../../src/ports/logger-port'; +import type ReadModelStore from '../../src/read-model/read-model-store'; + +import { + ActionFormValidationError, + ActionRequiresApprovalError, + AgentHttpError, + UnknownActionFieldError, +} from '@forestadmin/agent-client'; +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; +import request from 'supertest'; + +import createActionRoutesMiddleware from '../../src/action/action-routes-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import SchemaUnavailableError from '../../src/read-model/errors'; +import ReadModel from '../../src/read-model/read-model'; +import { action, collection, column } from '../read-model/fixtures'; + +const TIMEZONE = 'Europe/Paris'; + +const noopLogger: Logger = () => {}; + +function storeOf(readModel: ReadModel | Error): ReadModelStore { + return { + getReadModel: async () => { + if (readModel instanceof Error) throw readModel; + + return readModel; + }, + } as unknown as ReadModelStore; +} + +interface FakeField { + name: string; + type: string; + value: unknown; + isRequired: boolean; + enumValues?: string[]; +} + +function makeAction({ + fields = [], + layout = [], + skipped = [], + postChange, + execute = jest.fn(), + setFields = jest.fn(async () => {}), +}: { + fields?: FakeField[]; + layout?: unknown[]; + skipped?: string[]; + postChange?: { fields?: FakeField[]; layout?: unknown[] }; + execute?: jest.Mock; + setFields?: jest.Mock; +} = {}) { + const state = { fields: [...fields], layout: [...layout] }; + const tryToSetFields = jest.fn(async () => { + if (postChange) { + state.fields = postChange.fields ?? state.fields; + state.layout = postChange.layout ?? state.layout; + } + + return skipped; + }); + + const form: Action & { tryToSetFields: jest.Mock; execute: jest.Mock; setFields: jest.Mock } = { + execute, + setFields, + tryToSetFields, + getFields: () => + state.fields.map(f => ({ + getName: () => f.name, + getType: () => f.type, + getValue: () => f.value, + isRequired: () => f.isRequired, + })), + getEnumField: (name: string) => ({ + getOptions: () => state.fields.find(f => f.name === name)?.enumValues, + }), + getLayout: () => ({ layout: state.layout }), + }; + + return form; +} + +function clientOf( + form: Action, + loadAction: jest.Mock = jest.fn(async () => form), +): AgentActionClient & { loadAction: jest.Mock } { + return { loadAction }; +} + +function buildApp( + store: ReadModelStore, + client: AgentActionClient, + { agentToken = 'agent-jwt' }: { agentToken?: string | null } = {}, +) { + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: noopLogger })); + app.use(bodyParser()); + app.use(async (ctx, next) => { + ctx.state.timezone = TIMEZONE; + if (agentToken !== null) ctx.state.agentToken = agentToken; + await next(); + }); + app.use( + createActionRoutesMiddleware({ + store, + agentUrl: 'https://agent.example.com', + logger: noopLogger, + createClient: () => client, + }), + ); + + return app; +} + +const readModel = new ReadModel([ + collection('users', [column('id')], [action('approve', '/forest/_actions/users/0/approve')]), +]); + +function buildAppWithTerminal(client: AgentActionClient) { + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: noopLogger })); + app.use(bodyParser()); + app.use(async (ctx, next) => { + ctx.state.timezone = TIMEZONE; + ctx.state.agentToken = 'agent-jwt'; + await next(); + }); + app.use( + createActionRoutesMiddleware({ + store: storeOf(readModel), + agentUrl: 'https://agent.example.com', + logger: noopLogger, + createClient: () => client, + }), + ); + app.use(async ctx => { + ctx.status = 204; + }); + + return app; +} + +describe('action routes middleware', () => { + it('returns the full form shape with fields, canExecute, requiredFields, skippedFields and layout', async () => { + const form = makeAction({ + fields: [{ name: 'reason', type: 'String', value: null, isRequired: true }], + layout: [{ component: 'input', fieldId: 'reason' }], + skipped: [], + }); + const app = buildApp(storeOf(readModel), clientOf(form)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'], values: {} }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + fields: [{ name: 'reason', type: 'String', value: null, isRequired: true }], + canExecute: false, + requiredFields: ['reason'], + skippedFields: [], + layout: [{ component: 'input', fieldId: 'reason' }], + }); + }); + + it('lists unknown submitted values in skippedFields', async () => { + const form = makeAction({ + fields: [{ name: 'reason', type: 'String', value: 'x', isRequired: false }], + skipped: ['ghost'], + }); + const app = buildApp(storeOf(readModel), clientOf(form)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'], values: { ghost: 1 } }); + + expect(form.tryToSetFields).toHaveBeenCalledWith({ ghost: 1 }); + expect(response.body.skippedFields).toEqual(['ghost']); + }); + + it('reports canExecute true when every required field has a value', async () => { + const form = makeAction({ + fields: [{ name: 'reason', type: 'String', value: 'ok', isRequired: true }], + }); + const app = buildApp(storeOf(readModel), clientOf(form)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(response.body).toMatchObject({ canExecute: true, requiredFields: [] }); + }); + + it('reads fields and layout after tryToSetFields when a change hook rebuilds the form', async () => { + const form = makeAction({ + fields: [{ name: 'reason', type: 'String', value: null, isRequired: true }], + layout: [{ component: 'input', fieldId: 'reason' }], + postChange: { + fields: [{ name: 'comment', type: 'String', value: 'added', isRequired: false }], + layout: [{ component: 'input', fieldId: 'comment' }], + }, + }); + const app = buildApp(storeOf(readModel), clientOf(form)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'], values: { reason: 'x' } }); + + expect(response.body.fields).toEqual([ + { name: 'comment', type: 'String', value: 'added', isRequired: false }, + ]); + expect(response.body.layout).toEqual([{ component: 'input', fieldId: 'comment' }]); + }); + + it('never executes the action while loading its form', async () => { + const form = makeAction(); + const app = buildApp(storeOf(readModel), clientOf(form)); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(form.execute).not.toHaveBeenCalled(); + }); + + it('passes an opaque composite recordId through unchanged', async () => { + const form = makeAction(); + const loadAction = jest.fn(async () => form); + const app = buildApp(storeOf(readModel), clientOf(form, loadAction)); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['1|2'] }); + + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['1|2']); + }); + + it('coerces a numeric zero recordId to a string so it survives the downstream filter', async () => { + const form = makeAction(); + const loadAction = jest.fn(async () => form); + const app = buildApp(storeOf(readModel), clientOf(form, loadAction)); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: [0] }); + + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['0']); + }); + + it('accepts an empty recordIds array for a global action', async () => { + const form = makeAction(); + const loadAction = jest.fn(async () => form); + const app = buildApp(storeOf(readModel), clientOf(form, loadAction)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: [] }); + + expect(response.status).toBe(200); + expect(loadAction).toHaveBeenCalledWith('users', 'approve', []); + }); + + it('returns 400 invalid_request with no agent call when recordIds is missing', async () => { + const loadAction = jest.fn(); + const app = buildApp(storeOf(readModel), clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ values: {} }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('returns 400 invalid_request when recordIds is not an array', async () => { + const loadAction = jest.fn(); + const app = buildApp(storeOf(readModel), clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: '42' }); + + expect(response.status).toBe(400); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('returns 400 invalid_request when values is not an object', async () => { + const loadAction = jest.fn(); + const app = buildApp(storeOf(readModel), clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'], values: 'nope' }); + + expect(response.status).toBe(400); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('returns 404 unknown_action for an action that is not exposed', async () => { + const loadAction = jest.fn(); + const app = buildApp(storeOf(readModel), clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/ghost/form') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(404); + expect(response.body.error).toMatchObject({ type: 'unknown_action', status: 404 }); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('maps an agent failure on form load through the error contract', async () => { + const loadAction = jest.fn(async () => { + throw new AgentHttpError( + 403, + { errors: [{ status: 403, name: 'ForbiddenError' }] }, + undefined, + ); + }); + const app = buildApp(storeOf(readModel), clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ type: 'forbidden', status: 403 }); + }); + + it('maps an agent failure on tryToSetFields through the error contract', async () => { + const form = makeAction(); + form.tryToSetFields.mockRejectedValueOnce( + new AgentHttpError(422, { errors: [{ status: 422, name: 'UnprocessableError' }] }, undefined), + ); + const app = buildApp(storeOf(readModel), clientOf(form)); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'], values: { reason: 'x' } }); + + expect(response.status).toBe(422); + expect(response.body.error).toMatchObject({ type: 'unprocessable_entity', status: 422 }); + }); + + it('returns 401 when no agent token is available', async () => { + const loadAction = jest.fn(); + const app = buildApp(storeOf(readModel), clientOf(makeAction(), loadAction), { + agentToken: null, + }); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(401); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('falls through to the next middleware for a non-POST request on the form path', async () => { + const loadAction = jest.fn(); + const app = buildAppWithTerminal(clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()).get('/agent/v1/users/actions/approve/form'); + + expect(response.status).toBe(204); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('falls through to the next middleware for a path that is not an action form route', async () => { + const loadAction = jest.fn(); + const app = buildAppWithTerminal(clientOf(makeAction(), loadAction)); + + const response = await request(app.callback()).post('/agent/v1/users/list').send({}); + + expect(response.status).toBe(204); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('returns 503 schema_unavailable when the schema cannot be loaded', async () => { + const app = buildApp(storeOf(new SchemaUnavailableError()), clientOf(makeAction())); + + const response = await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(503); + expect(response.body.error).toMatchObject({ type: 'schema_unavailable', status: 503 }); + }); +}); + +describe('action execute', () => { + function execApp(client: AgentActionClient) { + return buildApp(storeOf(readModel), client); + } + + it('sets the submitted values then executes the action', async () => { + const setFields = jest.fn(async () => {}); + const execute = jest.fn(async () => ({ success: 'Done' })); + const form = makeAction({ setFields, execute }); + const loadAction = jest.fn(async () => form); + + await request(execApp(clientOf(form, loadAction)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'], values: { reason: 'x' } }); + + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42']); + expect(setFields).toHaveBeenCalledWith({ reason: 'x' }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('normalizes a Success result to 200 with invalidated as an array', async () => { + const form = makeAction({ + execute: jest.fn(async () => ({ + success: 'Refunded', + html: 'ok', + refresh: { relationships: ['orders'] }, + })), + }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + type: 'success', + message: 'Refunded', + invalidated: ['orders'], + html: 'ok', + }); + }); + + it('normalizes a Webhook result to 200 passing its fields through verbatim', async () => { + const form = makeAction({ + execute: jest.fn(async () => ({ + webhook: { url: 'https://x.test', method: 'POST', headers: { a: '1' }, body: { b: 2 } }, + })), + }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + type: 'webhook', + url: 'https://x.test', + method: 'POST', + headers: { a: '1' }, + body: { b: 2 }, + }); + }); + + it('normalizes a Redirect result to 200 with the path', async () => { + const form = makeAction({ execute: jest.fn(async () => ({ redirectTo: '/orders/1' })) }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ type: 'redirect', path: '/orders/1' }); + }); + + it('returns 501 unsupported_action_result for an unrecognized (File) result', async () => { + const form = makeAction({ execute: jest.fn(async () => ({})) }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(501); + expect(response.body).toEqual({ + error: { type: 'unsupported_action_result', status: 501 }, + }); + }); + + it('renders a native action Error as HTTP 400 with the forwarded html', async () => { + const form = makeAction({ + execute: jest.fn(async () => { + throw new ActionFormValidationError('Refund failed', 'Nope'); + }), + }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + type: 'error', + status: 400, + message: 'Refund failed', + html: 'Nope', + }); + }); + + it('rejects an unknown submitted field as 400 invalid_request', async () => { + const setFields = jest.fn(async () => { + throw new UnknownActionFieldError('ghost'); + }); + const execute = jest.fn(); + const form = makeAction({ setFields, execute }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'], values: { ghost: 1 } }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(execute).not.toHaveBeenCalled(); + }); + + it('maps an approval-gated action to 403 action_requires_approval with the allowed roles', async () => { + const form = makeAction({ + execute: jest.fn(async () => { + throw new ActionRequiresApprovalError('Needs approval', [7, 9]); + }), + }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ + type: 'action_requires_approval', + status: 403, + details: { roleIdsAllowedToApprove: [7, 9] }, + }); + }); + + it('keeps an empty roleIdsAllowedToApprove array in the 403 details', async () => { + const form = makeAction({ + execute: jest.fn(async () => { + throw new ActionRequiresApprovalError('Needs approval', []); + }), + }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(403); + expect(response.body.error.details).toEqual({ roleIdsAllowedToApprove: [] }); + }); + + it('maps a transport 5xx to agent_unavailable, distinct from the action-Error 400', async () => { + const form = makeAction({ + execute: jest.fn(async () => { + throw new AgentHttpError(502, null); + }), + }); + + const response = await request(execApp(clientOf(form)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(503); + expect(response.body.error).toMatchObject({ type: 'agent_unavailable', status: 503 }); + }); + + it('returns 400 invalid_request with no agent call when recordIds is missing', async () => { + const loadAction = jest.fn(); + const form = makeAction(); + + const response = await request(execApp(clientOf(form, loadAction)).callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ values: {} }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ type: 'invalid_request', status: 400 }); + expect(loadAction).not.toHaveBeenCalled(); + }); + + it('returns 404 unknown_action for an action that is not exposed', async () => { + const loadAction = jest.fn(); + const form = makeAction(); + + const response = await request(execApp(clientOf(form, loadAction)).callback()) + .post('/agent/v1/users/actions/ghost/execute') + .send({ recordIds: ['42'] }); + + expect(response.status).toBe(404); + expect(response.body.error).toMatchObject({ type: 'unknown_action', status: 404 }); + expect(loadAction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-bff/test/action/agent-action-client.test.ts b/packages/agent-bff/test/action/agent-action-client.test.ts new file mode 100644 index 0000000000..c6fd64574b --- /dev/null +++ b/packages/agent-bff/test/action/agent-action-client.test.ts @@ -0,0 +1,33 @@ +import { createRemoteAgentClient } from '@forestadmin/agent-client'; + +import createAgentActionClient from '../../src/action/agent-action-client'; + +jest.mock('@forestadmin/agent-client', () => ({ createRemoteAgentClient: jest.fn() })); + +const createRemoteAgentClientMock = createRemoteAgentClient as jest.Mock; + +describe('createAgentActionClient', () => { + it('loads the action via createRemoteAgentClient().collection(name).action(name, { recordIds })', async () => { + const loadedAction = { tag: 'action' }; + const actionFn = jest.fn(async () => loadedAction); + const collectionFn = jest.fn(() => ({ action: actionFn })); + createRemoteAgentClientMock.mockReturnValue({ collection: collectionFn }); + + const actionEndpoints = { users: { approve: {} } } as never; + const client = createAgentActionClient({ + agentUrl: 'https://agent.example.com', + token: 'agent-jwt', + actionEndpoints, + }); + const result = await client.loadAction('users', 'approve', ['1', '2']); + + expect(createRemoteAgentClientMock).toHaveBeenCalledWith({ + url: 'https://agent.example.com', + token: 'agent-jwt', + actionEndpoints, + }); + expect(collectionFn).toHaveBeenCalledWith('users'); + expect(actionFn).toHaveBeenCalledWith('approve', { recordIds: ['1', '2'] }); + expect(result).toBe(loadedAction); + }); +}); diff --git a/packages/agent-bff/test/http/agent-route-helpers.test.ts b/packages/agent-bff/test/http/agent-route-helpers.test.ts new file mode 100644 index 0000000000..f0b2609e36 --- /dev/null +++ b/packages/agent-bff/test/http/agent-route-helpers.test.ts @@ -0,0 +1,26 @@ +import type ReadModelStore from '../../src/read-model/read-model-store'; + +import { resolveReadModel } from '../../src/http/agent-route-helpers'; +import SchemaUnavailableError from '../../src/read-model/errors'; + +function storeThrowing(error: unknown): ReadModelStore { + return { + getReadModel: async () => { + throw error; + }, + } as unknown as ReadModelStore; +} + +describe('resolveReadModel', () => { + it('maps a SchemaUnavailableError to a 503 schema_unavailable error', async () => { + await expect( + resolveReadModel(storeThrowing(new SchemaUnavailableError())), + ).rejects.toMatchObject({ status: 503, type: 'schema_unavailable' }); + }); + + it('rethrows a non-schema error unchanged', async () => { + const boom = new Error('boom'); + + await expect(resolveReadModel(storeThrowing(boom))).rejects.toBe(boom); + }); +}); diff --git a/packages/agent-bff/test/validation/capabilities-validator.test.ts b/packages/agent-bff/test/validation/capabilities-validator.test.ts new file mode 100644 index 0000000000..6389b176db --- /dev/null +++ b/packages/agent-bff/test/validation/capabilities-validator.test.ts @@ -0,0 +1,228 @@ +import type { CapabilitiesResult } from '../../src/read-model/capabilities-cache'; + +import { toErrorBody } from '../../src/http/bff-http-error'; +import { + assertValidAgainstCapabilities, + validateAgainstCapabilities, +} from '../../src/validation/capabilities-validator'; + +const capabilities: CapabilitiesResult = { + fields: [ + { name: 'id', type: 'Number', operators: ['equal', 'in', 'greater_than'] }, + { name: 'title', type: 'String', operators: ['equal', 'contains', 'i_contains'] }, + { name: 'internalCode', type: 'String', operators: [] }, + { name: 'author', type: 'ManyToOne' }, + ], +}; + +function captureError(fn: () => void): unknown { + try { + fn(); + } catch (error) { + return error; + } + + throw new Error('expected to throw'); +} + +describe('validateAgainstCapabilities', () => { + describe('filter', () => { + it('rejects a leaf on a field absent from capabilities', () => { + const errors = validateAgainstCapabilities( + { filter: { field: 'missing', operator: 'Equal', value: 1 } }, + capabilities, + ); + + expect(errors).toHaveLength(1); + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'unknown_field', status: 422, details: { field: 'missing' } }), + ); + }); + + it('rejects a leaf nested under an And/Or group', () => { + const errors = validateAgainstCapabilities( + { + filter: { + aggregator: 'And', + conditions: [ + { field: 'id', operator: 'Equal', value: 1 }, + { aggregator: 'Or', conditions: [{ field: 'missing', operator: 'Equal', value: 2 }] }, + ], + }, + }, + capabilities, + ); + + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'unknown_field', details: { field: 'missing' } }), + ); + }); + + it('rejects a leaf on a field present with an empty operators array', () => { + const errors = validateAgainstCapabilities( + { filter: { field: 'internalCode', operator: 'Equal', value: 'x' } }, + capabilities, + ); + + expect(errors[0]).toEqual( + expect.objectContaining({ + type: 'field_not_filterable', + status: 422, + details: { field: 'internalCode' }, + }), + ); + }); + + it('treats a relation field with no operators key as not filterable', () => { + const errors = validateAgainstCapabilities( + { filter: { field: 'author', operator: 'Equal', value: 1 } }, + capabilities, + ); + + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'field_not_filterable', details: { field: 'author' } }), + ); + }); + + it('rejects an operator not in the field normalized operators, listing validOperators', () => { + const errors = validateAgainstCapabilities( + { filter: { field: 'title', operator: 'StartsWith', value: 'a' } }, + capabilities, + ); + + expect(errors[0]).toEqual( + expect.objectContaining({ + type: 'invalid_filter_operator', + status: 400, + details: { field: 'title', validOperators: ['Equal', 'Contains', 'IContains'] }, + }), + ); + }); + + it('rejects a filterable-field leaf that carries no operator', () => { + const errors = validateAgainstCapabilities({ filter: { field: 'title' } }, capabilities); + + expect(errors[0]).toEqual( + expect.objectContaining({ + type: 'invalid_filter_operator', + status: 400, + details: { field: 'title', validOperators: ['Equal', 'Contains', 'IContains'] }, + }), + ); + }); + + it('rejects a leaf whose operator is not a string', () => { + const errors = validateAgainstCapabilities( + { filter: { field: 'title', operator: 1, value: 'a' } }, + capabilities, + ); + + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'invalid_filter_operator', details: { field: 'title', validOperators: ['Equal', 'Contains', 'IContains'] } }), + ); + }); + + it('accepts an operator supported by the field', () => { + expect( + validateAgainstCapabilities( + { filter: { field: 'title', operator: 'IContains', value: 'a' } }, + capabilities, + ), + ).toEqual([]); + }); + + it('throws a 500 mapping_error when a capabilities operator does not normalize', () => { + const error = captureError(() => + validateAgainstCapabilities( + { filter: { field: 'weird', operator: 'Equal', value: 1 } }, + { fields: [{ name: 'weird', type: 'String', operators: ['made_up_operator'] }] }, + ), + ); + + expect(error).toEqual(expect.objectContaining({ type: 'mapping_error', status: 500 })); + }); + }); + + describe('sort and projection', () => { + it('rejects an unknown sort field', () => { + const errors = validateAgainstCapabilities({ sortFields: ['ghost'] }, capabilities); + + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'unknown_field', status: 422, details: { field: 'ghost' } }), + ); + }); + + it('rejects an unknown projection field', () => { + const errors = validateAgainstCapabilities({ projectionFields: ['ghost'] }, capabilities); + + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'unknown_field', status: 422, details: { field: 'ghost' } }), + ); + }); + + it('accepts a non-filterable field for sort and projection existence checks', () => { + expect( + validateAgainstCapabilities( + { sortFields: ['internalCode'], projectionFields: ['author'] }, + capabilities, + ), + ).toEqual([]); + }); + }); + + it('passes a fully valid filter, sort, and projection', () => { + expect( + validateAgainstCapabilities( + { + filter: { field: 'id', operator: 'GreaterThan', value: 1 }, + sortFields: ['title'], + projectionFields: ['id', 'title'], + }, + capabilities, + ), + ).toEqual([]); + }); + + it('rejects a projection field absent from capabilities, consulting no type table', () => { + const errors = validateAgainstCapabilities({ projectionFields: ['description'] }, capabilities); + + expect(errors[0]).toEqual( + expect.objectContaining({ type: 'unknown_field', details: { field: 'description' } }), + ); + }); + + it('reports every offending field in order and dedupes a field seen twice', () => { + const errors = validateAgainstCapabilities( + { + filter: { field: 'foo', operator: 'Equal', value: 1 }, + sortFields: ['bar'], + projectionFields: ['bar', 'baz'], + }, + capabilities, + ); + + expect(errors.map(e => (e.details as { field: string }).field)).toEqual(['foo', 'bar', 'baz']); + }); + + it('serializes an error to the wire envelope', () => { + const [error] = validateAgainstCapabilities({ sortFields: ['ghost'] }, capabilities); + + expect(toErrorBody(error)).toEqual({ + error: { type: 'unknown_field', status: 422, message: 'Unknown field: ghost', details: { field: 'ghost' } }, + }); + }); +}); + +describe('assertValidAgainstCapabilities', () => { + it('throws the first error', () => { + expect(() => + assertValidAgainstCapabilities({ sortFields: ['ghost'] }, capabilities), + ).toThrow(expect.objectContaining({ type: 'unknown_field', details: { field: 'ghost' } })); + }); + + it('does not throw for a valid request', () => { + expect(() => + assertValidAgainstCapabilities({ projectionFields: ['id'] }, capabilities), + ).not.toThrow(); + }); +}); diff --git a/packages/agent-bff/test/validation/operator-normalizer.test.ts b/packages/agent-bff/test/validation/operator-normalizer.test.ts new file mode 100644 index 0000000000..734e8c50f1 --- /dev/null +++ b/packages/agent-bff/test/validation/operator-normalizer.test.ts @@ -0,0 +1,36 @@ +import { allOperators } from '@forestadmin/datasource-toolkit'; + +import { normalizeOperator, toSnakeCaseOperator } from '../../src/validation/operator-normalizer'; + +describe('operator-normalizer', () => { + describe('normalizeOperator', () => { + it('maps a snake_case operator back to its PascalCase form', () => { + expect(normalizeOperator('less_than_or_equal')).toBe('LessThanOrEqual'); + }); + + it('maps a single-word operator', () => { + expect(normalizeOperator('blank')).toBe('Blank'); + }); + + it('maps a leading-initialism operator', () => { + expect(normalizeOperator('i_contains')).toBe('IContains'); + expect(normalizeOperator('not_i_contains')).toBe('NotIContains'); + expect(normalizeOperator('i_like')).toBe('ILike'); + }); + + it('maps an operator that embeds a single letter and a word', () => { + expect(normalizeOperator('after_x_hours_ago')).toBe('AfterXHoursAgo'); + expect(normalizeOperator('previous_x_days_to_date')).toBe('PreviousXDaysToDate'); + }); + + it('round-trips every canonical operator through snake_case', () => { + allOperators.forEach(operator => { + expect(normalizeOperator(toSnakeCaseOperator(operator))).toBe(operator); + }); + }); + + it('returns undefined for an operator absent from the canonical set', () => { + expect(normalizeOperator('made_up_operator')).toBeUndefined(); + }); + }); +}); diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index 44990589d9..bf9b042a4e 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -22,6 +22,7 @@ import AgentHttpError, { ActionFormValidationError, ActionRequiresApprovalError, ApprovalRequestCreationError, + UnknownActionFieldError, } from '../errors'; // JSON:API error body the agent returns on a rejected action. @@ -35,6 +36,7 @@ type ActionErrorBody = { }[]; error?: string; message?: string; + html?: string; data?: { roleIdsAllowedToApprove?: number[] }; }; @@ -72,7 +74,7 @@ function toActionError(error: unknown): unknown { } if (error.status === 400 || error.status === 422) { - return new ActionFormValidationError(detail ?? 'The action form values were rejected.'); + return new ActionFormValidationError(detail ?? 'The action form values were rejected.', body.html); } return error; @@ -184,7 +186,7 @@ export default class Action { async setFields(fields: Record): Promise { for (const [fieldName, value] of Object.entries(fields)) { if (!this.doesFieldExist(fieldName)) { - throw new Error(`Field "${fieldName}" does not exist in this form`); + throw new UnknownActionFieldError(fieldName); } // eslint-disable-next-line no-await-in-loop diff --git a/packages/agent-client/src/errors.ts b/packages/agent-client/src/errors.ts index cf3e8e9670..c824c0cab0 100644 --- a/packages/agent-client/src/errors.ts +++ b/packages/agent-client/src/errors.ts @@ -19,12 +19,22 @@ export class ActionRequiresApprovalError extends Error { } export class ActionFormValidationError extends Error { - constructor(message: string) { + constructor( + message: string, + readonly html?: string, + ) { super(message); this.name = 'ActionFormValidationError'; } } +export class UnknownActionFieldError extends Error { + constructor(readonly fieldName: string) { + super(`Field "${fieldName}" does not exist in this form`); + this.name = 'UnknownActionFieldError'; + } +} + // The action is approval-gated, but filing the approval request failed — distinct from the action // itself failing, so the caller can tell the two apart. export class ApprovalRequestCreationError extends Error { diff --git a/packages/agent-client/src/index.ts b/packages/agent-client/src/index.ts index 0b4665c186..a7e3951ad3 100644 --- a/packages/agent-client/src/index.ts +++ b/packages/agent-client/src/index.ts @@ -13,6 +13,7 @@ import AgentHttpError, { ActionFormValidationError, ActionRequiresApprovalError, ApprovalRequestCreationError, + UnknownActionFieldError, } from './errors'; import HttpRequester from './http-requester'; @@ -26,6 +27,7 @@ export { ActionRequiresApprovalError, ActionFormValidationError, ApprovalRequestCreationError, + UnknownActionFieldError, }; export type { ActionEndpointsByCollection, diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 423cfe770e..6810b2a084 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -303,6 +303,18 @@ describe('Action', () => { }); }); + it('forwards the html from a native action Error result', async () => { + httpRequester.query.mockRejectedValue( + new AgentHttpError(400, { error: 'Refund failed', html: 'Nope' }), + ); + + await expect(action.execute()).rejects.toMatchObject({ + name: 'ActionFormValidationError', + message: 'Refund failed', + html: 'Nope', + }); + }); + it('should propagate other HTTP errors unchanged', async () => { const error = new AgentHttpError(500, null); httpRequester.query.mockRejectedValue(error); @@ -338,14 +350,18 @@ describe('Action', () => { expect(callOrder).toEqual(['first', 'second', 'third']); }); - it('should throw when field does not exist', async () => { + it('should throw a typed UnknownActionFieldError when field does not exist', async () => { fieldsFormStates.getField.mockReturnValue(null); await expect( action.setFields({ nonexistent: 'value', }), - ).rejects.toThrow('Field "nonexistent" does not exist in this form'); + ).rejects.toMatchObject({ + name: 'UnknownActionFieldError', + fieldName: 'nonexistent', + message: 'Field "nonexistent" does not exist in this form', + }); }); });