diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 01732f8da2..82e4afdccd 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -16,6 +16,7 @@ import createApiKeyMiddleware from './api-key/api-key-middleware'; import createResolveCache from './api-key/resolve-cache'; import createAuthModeMiddleware from './auth/auth-mode-middleware'; import { parseConfig } from './config/env-config'; +import createContextRoutesMiddleware from './context/context-routes-middleware'; import createCorsMiddleware from './cors/cors-middleware'; import createPerKeyOriginMiddleware from './cors/per-key-origin'; import createDataRoutesMiddleware from './data/data-routes-middleware'; @@ -90,13 +91,18 @@ function resolveOAuthConfig(config: BFFConfig): ResolvedOAuthConfig | undefined return undefined; } -async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise { +interface OAuthEdge { + middlewares: Middleware[]; + environmentId?: number; +} + +async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise { const oauthConfig = resolveOAuthConfig(config); if (!oauthConfig) { logger('Warn', 'OAuth routes disabled: required configuration is missing'); - return []; + return { middlewares: [] }; } const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } = @@ -120,7 +126,7 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise logger, }); - return [oauthRoutes]; + return { middlewares: [oauthRoutes], environmentId }; } interface ResolvedApiKeyConfig { @@ -265,7 +271,11 @@ function buildAgentRouteMiddlewares( ]; } -function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { +function buildAgentMiddlewares( + config: BFFConfig, + logger: Logger, + environmentId?: number, +): Middleware[] { const { forestAuthSecret, defaultTimezone } = config; if (!forestAuthSecret) { @@ -285,6 +295,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] apiKeyStep, createPerKeyOriginMiddleware(), createOpenApiRoutes({ version, enabled: config.openapiEnabled, source }), + ...(bundle ? [createContextRoutesMiddleware({ store: bundle.store, environmentId })] : []), createTimezoneMiddleware({ defaultTimezone }), ...buildAgentRouteMiddlewares(bundle, config, logger), ]; @@ -304,15 +315,15 @@ export default async function runCli( }); } - const oauthMiddlewares = await buildOAuthMiddlewares(config, logger); - const agentMiddlewares = buildAgentMiddlewares(config, logger); + const oauth = await buildOAuthMiddlewares(config, logger); + const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth.environmentId); const agentErrorMiddleware = agentMiddlewares.length > 0 ? [agentScoped(createErrorMiddleware({ logger }))] : []; const middlewares = [ createCorsMiddleware({ allowedOrigins: config.allowedOrigins }), ...agentErrorMiddleware, bodyParser({ jsonLimit: BODY_LIMIT }), - ...oauthMiddlewares, + ...oauth.middlewares, ...agentMiddlewares, ]; const server = new BFFHttpServer({ diff --git a/packages/agent-bff/src/context/build-context.ts b/packages/agent-bff/src/context/build-context.ts new file mode 100644 index 0000000000..9528abdf4f --- /dev/null +++ b/packages/agent-bff/src/context/build-context.ts @@ -0,0 +1,153 @@ +import type { FieldType } from '../read-model/capabilities-cache'; +import type ReadModel from '../read-model/read-model'; +import type { RelationshipType } from '../read-model/read-model'; +import type { + ForestSchemaAction, + ForestSchemaCollection, + ForestSchemaField, +} from '@forestadmin/forestadmin-client'; + +export interface ContextActionField { + field: string; + type: FieldType; + isRequired?: boolean; + defaultValue?: unknown; + enums?: string[]; +} + +export interface ContextAction { + id: string; + name: string; + type: ForestSchemaAction['type']; + fields: ContextActionField[]; +} + +export interface ContextValidation { + type: string; + value?: unknown; +} + +export interface ContextField { + field: string; + type: FieldType; + relationship?: RelationshipType; + reference?: string; + inverseOf?: string; + polymorphicTargets?: string[]; + isPrimaryKey?: boolean; + isRequired?: boolean; + isReadOnly?: boolean; + enums?: string[]; + validations?: ContextValidation[]; +} + +export interface ContextCollection { + name: string; + fields: ContextField[]; + actions: ContextAction[]; +} + +export interface ContextMeta { + schemaRevision: number; + environmentId?: number; +} + +export interface AgentContext { + collections: ContextCollection[]; + meta: ContextMeta; +} + +function toArray(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + +type FieldWithWireEnums = ForestSchemaField & { enums?: string[] }; + +function toContextValidations(validations: unknown[] | null | undefined): ContextValidation[] { + return toArray(validations) + .filter( + (entry): entry is { type: string; value?: unknown } => + typeof entry === 'object' && + entry !== null && + typeof (entry as { type?: unknown }).type === 'string', + ) + .map(entry => + 'value' in entry ? { type: entry.type, value: entry.value } : { type: entry.type }, + ); +} + +function toContextField(field: FieldWithWireEnums): ContextField { + const serialized: ContextField = { field: field.field, type: field.type }; + + if (field.relationship) serialized.relationship = field.relationship; + if (field.reference) serialized.reference = field.reference; + if (field.inverseOf) serialized.inverseOf = field.inverseOf; + + const polymorphicTargets = toArray(field.polymorphicReferencedModels); + if (polymorphicTargets.length > 0) serialized.polymorphicTargets = [...polymorphicTargets]; + + if (field.isPrimaryKey) serialized.isPrimaryKey = true; + if (field.isRequired) serialized.isRequired = true; + if (field.isReadOnly) serialized.isReadOnly = true; + + const enums = toArray(field.enums); + if (enums.length > 0) serialized.enums = [...enums]; + + const validations = toContextValidations(field.validations); + if (validations.length > 0) serialized.validations = validations; + + return serialized; +} + +function toContextActionField(field: ForestSchemaAction['fields'][number]): ContextActionField { + const serialized: ContextActionField = { field: field.field, type: field.type }; + + if (field.isRequired !== undefined) serialized.isRequired = field.isRequired; + if (field.defaultValue !== undefined) serialized.defaultValue = field.defaultValue; + if (Array.isArray(field.enums)) serialized.enums = [...field.enums]; + + return serialized; +} + +function toContextAction(action: ForestSchemaAction): ContextAction { + return { + id: action.id, + name: action.name, + type: action.type, + fields: toArray(action.fields) + .filter(field => typeof field === 'object' && field !== null) + .map(toContextActionField), + }; +} + +function toContextCollection( + collection: ForestSchemaCollection, + readModel: ReadModel, +): ContextCollection { + return { + name: collection.name, + fields: toArray(collection.fields).map(toContextField), + actions: toArray(collection.actions) + .filter(action => readModel.isActionAllowed(collection.name, action.name)) + .map(toContextAction), + }; +} + +function toContextMeta({ schemaRevision, environmentId }: ContextMeta): ContextMeta { + const meta: ContextMeta = { schemaRevision }; + + if (environmentId !== undefined) meta.environmentId = environmentId; + + return meta; +} + +export default function buildContext( + collections: ForestSchemaCollection[], + readModel: ReadModel, + meta: ContextMeta, +): AgentContext { + return { + collections: collections.map(collection => toContextCollection(collection, readModel)), + meta: toContextMeta(meta), + }; +} diff --git a/packages/agent-bff/src/context/context-routes-middleware.ts b/packages/agent-bff/src/context/context-routes-middleware.ts new file mode 100644 index 0000000000..e3c131d6b1 --- /dev/null +++ b/packages/agent-bff/src/context/context-routes-middleware.ts @@ -0,0 +1,30 @@ +import type ReadModelStore from '../read-model/read-model-store'; +import type { Middleware } from 'koa'; + +import buildContext from './build-context'; +import { resolveSchemaSnapshot } from '../http/agent-route-helpers'; + +const CONTEXT_ROUTE = '/agent/v1/context'; + +export interface ContextRoutesMiddlewareOptions { + store: ReadModelStore; + environmentId?: number; +} + +export default function createContextRoutesMiddleware({ + store, + environmentId, +}: ContextRoutesMiddlewareOptions): Middleware { + return async function contextRoutesMiddleware(ctx, next) { + if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') { + await next(); + + return; + } + + const { collections, readModel, revision } = await resolveSchemaSnapshot(store); + + ctx.status = 200; + ctx.body = buildContext(collections, readModel, { schemaRevision: revision, environmentId }); + }; +} diff --git a/packages/agent-bff/src/http/agent-route-helpers.ts b/packages/agent-bff/src/http/agent-route-helpers.ts index 996c9896aa..d9fb3332c7 100644 --- a/packages/agent-bff/src/http/agent-route-helpers.ts +++ b/packages/agent-bff/src/http/agent-route-helpers.ts @@ -1,6 +1,7 @@ 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 { SchemaSnapshot } from '../read-model/read-model-store'; import type { Context } from 'koa'; import { mapAgentError } from './agent-error-mapper'; @@ -16,15 +17,23 @@ export function decodeSegment(raw: string, label: string): string { } } -export async function resolveReadModel(store: ReadModelStore): Promise { +async function mapSchemaFailure(read: () => Promise): Promise { try { - return await store.getReadModel(); + return await read(); } catch (error) { if (error instanceof SchemaUnavailableError) throw schemaUnavailable(); throw error; } } +export async function resolveSchemaSnapshot(store: ReadModelStore): Promise { + return mapSchemaFailure(() => store.getSchemaSnapshot()); +} + +export async function resolveReadModel(store: ReadModelStore): Promise { + return mapSchemaFailure(() => store.getReadModel()); +} + export function requireAgentToken(ctx: Context): string { const token = ctx.state.agentToken as string | undefined; if (!token) throw unauthorized('No agent credentials for this request'); diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index ccb63074c6..68d147b645 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -6,6 +6,7 @@ import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-ope import ComponentPool from './component-pool'; import { ActionRequestSchema, + ContextResponseSchema, CountRequestSchema, CountResponseSchema, ErrorResponseSchema, @@ -36,7 +37,7 @@ const ERROR_STATUSES: Record = { 415: 'The request declares a character set the server cannot decode. Other content types are NOT rejected: a form-urlencoded body is parsed and validated like JSON (its values arrive as strings, so typed fields such as page.limit fail with 400), while any other non-JSON content type is read as an absent body, silently dropping filters and pagination', 422: 'A field is unknown, not filterable, or is a nested relation path', 429: 'The agent rate-limited the request', - 500: 'The agent payload could not be mapped to the BFF contract', + 500: 'The agent payload could not be mapped to the BFF contract, or the BFF hit an unexpected error', 501: 'The BFF is running without an agent configured, so the proxy is not implemented', 502: 'The agent could not be reached', 503: 'The agent schema is unavailable, the agent returned a 5xx, or the API key could not be resolved', @@ -277,9 +278,8 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): type: 'http', scheme: 'bearer', description: - 'Mode 1: the BFF session token issued after the OAuth login. It authenticates the caller ' + - 'but the data and action routes reject it until the BFF mints an agent token from the ' + - 'OAuth principal, so no operation lists it yet. Use the API key today.', + 'Mode 1: the BFF session token issued after the OAuth login. Accepted on the context ' + + 'contract; the data and action routes advertise the API key only.', }); registry.registerComponent('securitySchemes', API_KEY_SCHEME, { type: 'apiKey', @@ -288,6 +288,27 @@ export function generateOpenApiDocument(version: string, unfolding?: Unfolding): description: 'Mode 2: a BFF API key. Never send both this and an Authorization header.', }); + registry.registerPath({ + method: 'get', + path: `${ROUTE_PREFIX}/context`, + operationId: 'getContext', + summary: 'Read the exposed schema contract', + security: [{ [SESSION_SCHEME]: [] }, { [API_KEY_SCHEME]: [] }], + request: {}, + responses: { + 200: { + description: 'The exposed schema: collections, typed fields, relations and actions', + content: { 'application/json': { schema: ContextResponseSchema } }, + }, + 400: errorRefs.byStatus['400'], + 401: errorRefs.byStatus['401'], + 403: errorRefs.byStatus['403'], + 500: errorRefs.byStatus['500'], + 501: errorRefs.byStatus['501'], + 503: errorRefs.byStatus['503'], + }, + }); + if (unfolding) { registerUnfoldedPaths( { diff --git a/packages/agent-bff/src/openapi/schemas.ts b/packages/agent-bff/src/openapi/schemas.ts index f2655f5c48..edd467dfae 100644 --- a/packages/agent-bff/src/openapi/schemas.ts +++ b/packages/agent-bff/src/openapi/schemas.ts @@ -1,6 +1,7 @@ import { allOperators } from '@forestadmin/datasource-toolkit'; import { z } from './zod-openapi'; +import { RELATIONSHIP_TYPES } from '../read-model/read-model'; import { MAX_FILTER_DEPTH } from '../validation/capabilities-validator'; const OPERATORS = [...allOperators] as [string, ...string[]]; @@ -143,6 +144,111 @@ export const CountResponseSchema = z 'collection disables count. The pair never disagrees.', }); +const ContextFieldTypeSchema: z.ZodType = z + .lazy(() => + z.union([ + z.string(), + z.array(ContextFieldTypeSchema), + z.object({ + fields: z.array( + z.object({ + field: z.string(), + type: ContextFieldTypeSchema, + enums: z.array(z.string()).optional(), + }), + ), + }), + ]), + ) + .openapi('ContextFieldType', { + description: + 'The field type, passed through from the agent wire format without normalization: a string ' + + 'for a primitive, an array of types for an array field, or an object carrying `fields` for ' + + 'a composite. A composite sub-field carries its own `type`, recursively, and its own ' + + '`enums` when it is an enum. A consumer that only understands primitives should ignore the ' + + 'other two rather than fail.', + }); + +const ContextValidationSchema = z + .object({ type: z.string(), value: z.unknown().optional() }) + .openapi('ContextValidation', { + description: + 'A validation rule as the agent states it. `type` is the Forest wording (`is like`, `is ' + + 'present`, `is longer than`, …) and `value` is passed through unchanged, so its shape ' + + 'follows the rule: a number for a length rule, a date for a comparison. On `is like` it is ' + + 'the JavaScript **literal** form of the regular expression — slashes included, and flags ' + + 'after the closing one (`/^data:.*;base64,.*/`, but also `/^a|b|c$/g`). Parse it as a ' + + 'literal, splitting on the LAST slash to separate pattern from flags; stripping the outer ' + + 'characters instead leaves the flags inside the pattern, which then matches nothing. A ' + + 'rule with no operand carries no `value`.', + }); + +const ContextFieldSchema = z.object({ + field: z.string(), + type: ContextFieldTypeSchema, + relationship: z.enum(RELATIONSHIP_TYPES).optional(), + reference: z.string().optional(), + inverseOf: z.string().optional(), + polymorphicTargets: z.array(z.string()).optional(), + isPrimaryKey: z.boolean().optional(), + isRequired: z.boolean().optional(), + isReadOnly: z.boolean().optional(), + enums: z.array(z.string()).optional(), + validations: z.array(ContextValidationSchema).optional(), +}); + +const ContextActionSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(['single', 'bulk', 'global']), + fields: z.array( + z.object({ + field: z.string(), + type: ContextFieldTypeSchema, + isRequired: z.boolean().optional(), + defaultValue: z.unknown().optional(), + enums: z.array(z.string()).optional(), + }), + ), +}); + +export const ContextResponseSchema = z + .object({ + collections: z.array( + z.object({ + name: z.string(), + fields: z.array(ContextFieldSchema), + actions: z.array(ContextActionSchema), + }), + ), + meta: z.object({ schemaRevision: z.number(), environmentId: z.number().optional() }), + }) + .openapi('ContextResponse', { + description: + 'Everything the agent schema exposes: collection names, typed fields with their relation ' + + 'markers, and the custom actions that carry an endpoint. Field types are passed through ' + + 'from the agent wire format, so a type is a string, an array of types, or a composite ' + + 'object. A `Binary` column is advertised as `String`, because that is what it is on the ' + + 'wire — the bytes travel as a data uri or as hex — so `type` alone does not tell a text ' + + 'field from an encoded one: read `validations` for that. `reference` keeps the raw agent ' + + 'form, the foreign collection and the key joined by a dot — the collection name may itself ' + + 'contain dots, so drop only the trailing segment to recover it. A target named by ' + + '`reference` or `polymorphicTargets` is NOT guaranteed to appear in `collections[]`: the ' + + 'schema describes a field as the agent declares it, and a relation can point at a ' + + 'collection this document does not expose. Cross a target against `collections[]` before ' + + 'following it — unlike the per-collection route documents, nothing here is dropped for ' + + 'pointing outside the served set. ' + + 'The document carries no rendering, project or team identity, and the only environment ' + + 'datum is `meta.environmentId` below. It is served to both auth modes — an OAuth session ' + + 'and a BFF API key get the same document. It is NOT filtered by the caller permissions: ' + + 'it describes the whole exposed schema minus the endpoint-less actions, so cross it with ' + + '`/agent/v1/permissions` to know what the caller may actually ' + + 'see. `meta.schemaRevision` increments whenever the BFF refreshes its schema, and resets ' + + 'when the BFF restarts. `meta.environmentId` is the environment the BFF resolved at boot ' + + 'from its own secret — it is telemetry, not a routing input, and it is absent when the ' + + 'deployment runs without the OAuth configuration.', + }); + export const ErrorResponseSchema = z .object({ error: z.object({ diff --git a/packages/agent-bff/src/read-model/capabilities-cache.ts b/packages/agent-bff/src/read-model/capabilities-cache.ts index 46d2347119..0c2f3dd307 100644 --- a/packages/agent-bff/src/read-model/capabilities-cache.ts +++ b/packages/agent-bff/src/read-model/capabilities-cache.ts @@ -4,7 +4,10 @@ import { ONE_DAY_MS } from './schema-cache'; * A Forest column type: a primitive name, a one-element array wrapping another type, a composite * `{ fields }`, or a relation marker like `ManyToOne`. */ -export type FieldType = string | FieldType[] | { fields: { field: string; type: FieldType }[] }; +export type FieldType = + | string + | FieldType[] + | { fields: { field: string; type: FieldType; enums?: string[] }[] }; export interface CapabilitiesResult { // `type` is the agent's raw `columnType`, so it is not always a plain name: an array-of-primitive diff --git a/packages/agent-bff/src/read-model/read-model-store.ts b/packages/agent-bff/src/read-model/read-model-store.ts index bf1c226a82..3f3b677d53 100644 --- a/packages/agent-bff/src/read-model/read-model-store.ts +++ b/packages/agent-bff/src/read-model/read-model-store.ts @@ -1,11 +1,18 @@ import type CapabilitiesCache from './capabilities-cache'; import type { CapabilitiesFetcher, CapabilitiesResult } from './capabilities-cache'; import type SchemaCache from './schema-cache'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; import ReadModel from './read-model'; const MAX_GENERATION_RETRIES = 3; +export interface SchemaSnapshot { + collections: ForestSchemaCollection[]; + readModel: ReadModel; + revision: number; +} + /** * Single owner of the coupled schema + capabilities lifecycle. A successful schema refresh (a bump * of the cache `revision`) rebuilds the read-model and clears capabilities atomically, so the @@ -24,15 +31,20 @@ export default class ReadModelStore { } async getReadModel(): Promise { + return (await this.getSchemaSnapshot()).readModel; + } + + async getSchemaSnapshot(): Promise { const collections = await this.schemaCache.get(); + const { revision } = this.schemaCache; - if (this.schemaCache.revision !== this.builtRevision || !this.readModel) { + if (revision !== this.builtRevision || !this.readModel) { this.readModel = new ReadModel(collections); - this.builtRevision = this.schemaCache.revision; + this.builtRevision = revision; this.capabilitiesCache.clear(); } - return this.readModel; + return { collections, readModel: this.readModel, revision }; } /** diff --git a/packages/agent-bff/src/read-model/read-model.ts b/packages/agent-bff/src/read-model/read-model.ts index 20a620f290..6b6a275ad1 100644 --- a/packages/agent-bff/src/read-model/read-model.ts +++ b/packages/agent-bff/src/read-model/read-model.ts @@ -1,7 +1,14 @@ import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; import type { ForestSchemaCollection, ForestSchemaField } from '@forestadmin/forestadmin-client'; -export type RelationshipType = 'BelongsTo' | 'HasOne' | 'HasMany' | 'BelongsToMany'; +export const RELATIONSHIP_TYPES = [ + 'BelongsTo', + 'HasOne', + 'HasMany', + 'BelongsToMany', +] as const satisfies readonly NonNullable[]; + +export type RelationshipType = (typeof RELATIONSHIP_TYPES)[number]; export type RelationTarget = | { type: RelationshipType; polymorphic: false; target: string } diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index e147f420a5..550776b55a 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -164,6 +164,58 @@ describe('runCli', () => { } }); + it('should reach the context route without a timezone, since it is mounted before that middleware', async () => { + const token = jsonwebtoken.sign( + { type: 'bff_access', sid: 's1', id: 1, rendering_id: '1', tags: {} }, + VALID_ENV.FOREST_AUTH_SECRET, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + const server = await runCli(VALID_ENV, noopLogger); + + try { + const response = await request(server.callback) + .get('/agent/v1/context') + .set('Authorization', `Bearer ${token}`); + + expect(response.body.error?.type).not.toBe('missing_timezone'); + expect(response.body.error).toEqual( + expect.objectContaining({ type: 'schema_unavailable', status: 503 }), + ); + } finally { + await server.stop(); + } + }); + + it('should fall through to the agent stub when the deployment carries no read-model', async () => { + const token = jsonwebtoken.sign( + { type: 'bff_access', sid: 's1', id: 1, rendering_id: '1', tags: {} }, + VALID_ENV.FOREST_AUTH_SECRET, + { algorithm: 'HS256', expiresIn: '15m' }, + ); + const server = await runCli({ ...VALID_ENV, FOREST_ENV_SECRET: undefined }, noopLogger); + + try { + const withTimezone = await request(server.callback) + .get('/agent/v1/context') + .set('Authorization', `Bearer ${token}`) + .set('X-Forest-Timezone', 'Europe/Paris'); + const withoutTimezone = await request(server.callback) + .get('/agent/v1/context') + .set('Authorization', `Bearer ${token}`); + + expect(withTimezone.status).toBe(501); + expect(withTimezone.body.error).toEqual( + expect.objectContaining({ type: 'not_implemented', status: 501 }), + ); + expect(withoutTimezone.status).toBe(400); + expect(withoutTimezone.body.error).toEqual( + expect.objectContaining({ type: 'missing_timezone', status: 400 }), + ); + } finally { + await server.stop(); + } + }); + it('should carry an oauth Bearer past requireAgentToken on a data route', async () => { const token = jsonwebtoken.sign( { diff --git a/packages/agent-bff/test/context/build-context.test.ts b/packages/agent-bff/test/context/build-context.test.ts new file mode 100644 index 0000000000..587fdd6243 --- /dev/null +++ b/packages/agent-bff/test/context/build-context.test.ts @@ -0,0 +1,326 @@ +import schemaCoveringEveryContractShape from './fixtures'; +import buildContext from '../../src/context/build-context'; +import { ContextResponseSchema } from '../../src/openapi/schemas'; +import ReadModel from '../../src/read-model/read-model'; + +describe('buildContext', () => { + const schema = schemaCoveringEveryContractShape(); + const readModel = new ReadModel(schema); + + function usersOf(context: ReturnType) { + return context.collections.find(collection => collection.name === 'users'); + } + + function fieldNamed(context: ReturnType, name: string) { + return usersOf(context)?.fields.find(entry => entry.field === name); + } + + describe('when the schema carries every field shape', () => { + it('should pass a primitive, an array and a composite type through untouched', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')?.type).toBe('Uuid'); + expect(fieldNamed(context, 'tagsWithArrayType')?.type).toEqual(['String']); + expect(fieldNamed(context, 'matrixWithNestedArrayType')?.type).toEqual([['String']]); + expect(fieldNamed(context, 'addressesWithArrayOfComposite')?.type).toEqual([ + { fields: [{ field: 'label', type: 'String' }] }, + ]); + expect(fieldNamed(context, 'addressWithCompositeType')?.type).toEqual({ + fields: [ + { field: 'city', type: 'String' }, + { field: 'country', type: 'Enum', enums: ['FR', 'BE'] }, + { field: 'tags', type: ['String'] }, + ], + }); + }); + + it('should carry reference and inverseOf on a relation that has them', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'ordersHasManyWithInverseOf')).toEqual({ + field: 'ordersHasManyWithInverseOf', + type: 'String', + relationship: 'HasMany', + reference: 'orders.customerId', + inverseOf: 'customer', + }); + }); + + it('should omit inverseOf on a relation that lacks it', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const relation = fieldNamed(context, 'teamBelongsToWithoutInverseOf'); + + expect(relation).toEqual({ + field: 'teamBelongsToWithoutInverseOf', + type: 'String', + relationship: 'BelongsTo', + reference: 'teams.id', + }); + expect(relation).not.toHaveProperty('inverseOf'); + }); + + it('should name the targets of a polymorphic relation, which carries no reference', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'ownerPolymorphic')).toEqual({ + field: 'ownerPolymorphic', + type: 'String', + relationship: 'BelongsTo', + polymorphicTargets: ['users', 'teams'], + }); + }); + + it('should tell a to-many relation from a to-one, which share the reference shape', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'ordersHasManyWithInverseOf')?.relationship).toBe('HasMany'); + expect(fieldNamed(context, 'teamBelongsToWithoutInverseOf')?.relationship).toBe('BelongsTo'); + expect(fieldNamed(context, 'id')).not.toHaveProperty('relationship'); + }); + + it('should carry isRequired and isReadOnly only when true', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'emailRequired')?.isRequired).toBe(true); + expect(fieldNamed(context, 'lockedReadOnly')?.isReadOnly).toBe(true); + expect(fieldNamed(context, 'id')).not.toHaveProperty('isRequired'); + expect(fieldNamed(context, 'id')).not.toHaveProperty('isReadOnly'); + }); + }); + + describe('when a field is an enum or a primary key', () => { + it('should carry the allowed values, which the type alone does not give', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'statusWithEnums')?.enums).toEqual(['DRAFT', 'PUBLISHED']); + }); + + it('should flag the primary key, so a caller can build recordIds without a list call', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')?.isPrimaryKey).toBe(true); + expect(fieldNamed(context, 'emailRequired')).not.toHaveProperty('isPrimaryKey'); + }); + + it('should omit enums on a field that has none rather than send an empty list', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')).not.toHaveProperty('enums'); + }); + }); + + describe('validations', () => { + it('should keep the flags of a regex rule, which the agent emits on rewritten In filters', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'statusWithFlaggedPattern')?.validations).toEqual([ + { type: 'is like', value: '/^a|b|c$/g' }, + ]); + }); + + it('should carry the pattern a binary field requires, which the type alone cannot express', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'thumbnailWithPattern')?.validations).toEqual([ + { type: 'is like', value: '/^data:.*;base64,.*/' }, + ]); + }); + + it('should drop the message and invent no value on a rule that has neither', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'titleWithPresence')?.validations).toEqual([ + { type: 'is present' }, + ]); + }); + + it('should skip malformed entries instead of failing the whole contract', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'fieldWithMalformedValidations')?.validations).toEqual([ + { type: 'contains', value: 'ok' }, + ]); + }); + + it('should omit the key entirely when the agent sends null', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'fieldWithNullValidations')).not.toHaveProperty('validations'); + }); + + it('should omit the key on a field with no validation at all', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'id')).not.toHaveProperty('validations'); + }); + }); + + describe('when the schema carries actions', () => { + it('should expose single, bulk and global actions alike', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(usersOf(context)?.actions.map(entry => [entry.name, entry.type])).toEqual([ + ['Ban user', 'single'], + ['Export all', 'global'], + ['Archive', 'bulk'], + ]); + }); + + it('should drop an action with no endpoint, matching the read-model allow-list', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(readModel.isActionAllowed('users', 'Endpointless action')).toBe(false); + expect(usersOf(context)?.actions.map(entry => entry.name)).not.toContain( + 'Endpointless action', + ); + }); + + it('should keep a falsy default value and an empty enum list, which carry meaning', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const exportAction = usersOf(context)?.actions.find(entry => entry.name === 'Export all'); + + expect(exportAction?.fields).toEqual([ + { field: 'includeArchived', type: 'Boolean', isRequired: false, defaultValue: false }, + { field: 'limit', type: 'Number', defaultValue: 0 }, + { field: 'since', type: 'Date', defaultValue: null }, + { field: 'format', type: 'Enum', enums: [] }, + { field: 'loading', type: 'String' }, + ]); + }); + + it('should omit enums when the agent sends null, which it does on every dynamic form', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const loading = usersOf(context) + ?.actions.find(entry => entry.name === 'Export all') + ?.fields.find(entry => entry.field === 'loading'); + + expect(loading).not.toHaveProperty('enums'); + }); + + it('should skip a null action field rather than fail the whole contract', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const archive = usersOf(context)?.actions.find(entry => entry.name === 'Archive'); + + expect(archive?.fields).toEqual([{ field: 'confirm', type: 'Boolean' }]); + }); + + it('should serialize an action field with its enums and default value', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const banAction = usersOf(context)?.actions.find(entry => entry.name === 'Ban user'); + + expect(banAction?.fields).toEqual([ + { + field: 'reason', + type: 'Enum', + isRequired: true, + defaultValue: 'spam', + enums: ['spam', 'abuse'], + }, + ]); + }); + }); + + describe('when a relation points at a collection the document does not serve', () => { + it('should still name the target, leaving the cross-check to the consumer', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const served = context.collections.map(collection => collection.name); + + expect(served).not.toContain('orders'); + expect(served).not.toContain('teams'); + expect(fieldNamed(context, 'ordersHasManyWithInverseOf')?.reference).toBe( + 'orders.customerId', + ); + expect(fieldNamed(context, 'ownerPolymorphic')?.polymorphicTargets).toEqual([ + 'users', + 'teams', + ]); + }); + }); + + describe('when a relation targets a collection whose name contains dots', () => { + it('should keep the reference whole so only the trailing segment is the key', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(fieldNamed(context, 'addressBelongsToDottedCollection')?.reference).toBe( + 'User.address.city', + ); + }); + }); + + describe('when a collection name is unusual', () => { + it('should keep a dotted name and a name carrying a space verbatim', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(context.collections.map(collection => collection.name)).toEqual([ + 'users', + 'User.address', + 'My Coll', + 'collectionWithoutFieldsNorActions', + ]); + }); + }); + + describe('when a collection has neither fields nor actions', () => { + it('should serialize it with empty lists rather than throwing', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect( + context.collections.find( + collection => collection.name === 'collectionWithoutFieldsNorActions', + ), + ).toEqual({ name: 'collectionWithoutFieldsNorActions', fields: [], actions: [] }); + }); + }); + + describe('meta', () => { + it('should carry the schema revision it was built from', () => { + expect(buildContext(schema, readModel, { schemaRevision: 7 }).meta).toEqual({ + schemaRevision: 7, + }); + }); + + it('should carry the environment id when the deployment resolved one', () => { + expect( + buildContext(schema, readModel, { schemaRevision: 7, environmentId: 42 }).meta, + ).toEqual({ schemaRevision: 7, environmentId: 42 }); + }); + + it('should omit the environment id rather than send null when none was resolved', () => { + const { meta } = buildContext(schema, readModel, { schemaRevision: 7 }); + + expect(meta).not.toHaveProperty('environmentId'); + }); + }); + + describe('identity', () => { + it('should expose collections and meta only, so no identity can ride along', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + + expect(Object.keys(context).sort()).toEqual(['collections', 'meta']); + expect(Object.keys(context.meta)).toEqual(['schemaRevision']); + }); + + it('should expose only the agreed keys on a collection, a field and an action', () => { + const context = buildContext(schema, readModel, { schemaRevision: 1 }); + const users = usersOf(context); + + expect(Object.keys(users ?? {}).sort()).toEqual(['actions', 'fields', 'name']); + expect(Object.keys(fieldNamed(context, 'ordersHasManyWithInverseOf') ?? {}).sort()).toEqual([ + 'field', + 'inverseOf', + 'reference', + 'relationship', + 'type', + ]); + expect(Object.keys(users?.actions[0] ?? {}).sort()).toEqual(['fields', 'id', 'name', 'type']); + }); + }); + + describe('when the built context is validated against the published OpenAPI schema', () => { + it('should round-trip through ContextResponseSchema for every shape the fixture covers', () => { + const context = buildContext(schema, readModel, { schemaRevision: 3, environmentId: 42 }); + + expect(ContextResponseSchema.parse(context)).toStrictEqual(context); + }); + }); +}); diff --git a/packages/agent-bff/test/context/context-routes-middleware.test.ts b/packages/agent-bff/test/context/context-routes-middleware.test.ts new file mode 100644 index 0000000000..41a3e046ea --- /dev/null +++ b/packages/agent-bff/test/context/context-routes-middleware.test.ts @@ -0,0 +1,204 @@ +import type { SchemaFetcher } from '../../src/read-model/forest-schema-client'; +import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; + +import jsonwebtoken from 'jsonwebtoken'; +import Koa from 'koa'; +import request from 'supertest'; + +import schemaCoveringEveryContractShape from './fixtures'; +import createApiKeyMiddleware, { BFF_KEY_HEADER } from '../../src/api-key/api-key-middleware'; +import createAuthModeMiddleware from '../../src/auth/auth-mode-middleware'; +import createContextRoutesMiddleware from '../../src/context/context-routes-middleware'; +import createPerKeyOriginMiddleware from '../../src/cors/per-key-origin'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import CapabilitiesCache from '../../src/read-model/capabilities-cache'; +import ReadModelStore from '../../src/read-model/read-model-store'; +import SchemaCache, { ONE_DAY_MS } from '../../src/read-model/schema-cache'; +import { makeMetrics } from '../read-model/fixtures'; + +const ROUTE = '/agent/v1/context'; +const AUTH_SECRET = 'context-secret'; +const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; + +function makeStore(fetchSchema: jest.Mock, now?: () => number) { + const fetcher: SchemaFetcher = { fetchSchema }; + const schemaCache = new SchemaCache({ fetcher, metrics: makeMetrics(), now }); + + return { schemaCache, store: new ReadModelStore(schemaCache, new CapabilitiesCache({})) }; +} + +function makeRouteOnlyApp(fetchSchema: jest.Mock, environmentId?: number, now?: () => number) { + const { schemaCache, store } = makeStore(fetchSchema, now); + + const app = new Koa(); + app.use(createErrorMiddleware({ logger: () => {} })); + app.use(createContextRoutesMiddleware({ store, environmentId })); + + return { app, schemaCache }; +} + +function apiKeyIdentity(allowedOrigins: string[] = []) { + return { + user: { + id: 1, + email: 'a@b.com', + firstName: 'A', + lastName: 'B', + team: 'T', + tags: [], + permissionLevel: 'admin', + }, + renderingId: 1, + allowedOrigins, + }; +} + +function makeFullAgentEdge(fetchSchema: jest.Mock, allowedOrigins: string[] = []) { + const { store } = makeStore(fetchSchema); + const logger = () => undefined; + + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger })); + app.use(createAuthModeMiddleware({ authSecret: AUTH_SECRET })); + app.use( + createApiKeyMiddleware({ + authenticator: { + authenticate: async () => ({ + agentToken: 'agent-token', + identity: apiKeyIdentity(allowedOrigins), + }), + }, + logger, + }), + ); + app.use(createPerKeyOriginMiddleware()); + app.use(createContextRoutesMiddleware({ store })); + + return app.callback(); +} + +describe('contextRoutesMiddleware', () => { + let schema: ForestSchemaCollection[]; + + beforeEach(() => { + schema = schemaCoveringEveryContractShape(); + }); + + describe('when the route serves the contract', () => { + it('should serve the contract with its collections and schema revision', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeRouteOnlyApp(fetchSchema); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.status).toBe(200); + expect(response.body.collections.map((entry: { name: string }) => entry.name)).toEqual([ + 'users', + 'User.address', + 'My Coll', + 'collectionWithoutFieldsNorActions', + ]); + expect(response.body.meta).toEqual({ schemaRevision: 1 }); + }); + + it('should carry the environment id the deployment resolved at boot', async () => { + const { app } = makeRouteOnlyApp(jest.fn().mockResolvedValue(schema), 42); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.body.meta).toEqual({ schemaRevision: 1, environmentId: 42 }); + }); + + it('should omit the environment id when the deployment resolved none', async () => { + const { app } = makeRouteOnlyApp(jest.fn().mockResolvedValue(schema)); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.body.meta).toEqual({ schemaRevision: 1 }); + }); + + it('should fetch the schema once on a cold cache and never again while it stays warm', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeRouteOnlyApp(fetchSchema); + + await request(app.callback()).get(ROUTE); + await request(app.callback()).get(ROUTE); + + expect(fetchSchema).toHaveBeenCalledTimes(1); + }); + + it('should fetch the schema again once the cached one has outlived its ttl', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + let clock = 1_000_000; + const { app } = makeRouteOnlyApp(fetchSchema, undefined, () => clock); + + await request(app.callback()).get(ROUTE); + clock += ONE_DAY_MS + 1; + const response = await request(app.callback()).get(ROUTE); + + expect(fetchSchema).toHaveBeenCalledTimes(2); + expect(response.body.meta.schemaRevision).toBe(2); + }); + }); + + describe('when the schema cannot be read', () => { + it('should answer 503 schema_unavailable rather than an empty contract', async () => { + const fetchSchema = jest.fn().mockRejectedValue(new Error('agent down')); + const { app } = makeRouteOnlyApp(fetchSchema); + + const response = await request(app.callback()).get(ROUTE); + + expect(response.status).toBe(503); + expect(response.body.error).toMatchObject({ type: 'schema_unavailable', status: 503 }); + }); + }); + + describe('when the caller authenticated with an API key through the full edge', () => { + it('should serve the same contract an OAuth session gets, since the schema is not caller-scoped', async () => { + const sessionToken = jsonwebtoken.sign( + { type: 'bff_access', sid: 's1', rendering_id: '1', tags: {} }, + AUTH_SECRET, + { algorithm: 'HS256', expiresIn: '15m' } as jsonwebtoken.SignOptions, + ); + + const keyResponse = await request(makeFullAgentEdge(jest.fn().mockResolvedValue(schema))) + .get(ROUTE) + .set(BFF_KEY_HEADER, RAW_KEY); + const sessionResponse = await request(makeFullAgentEdge(jest.fn().mockResolvedValue(schema))) + .get(ROUTE) + .set('Authorization', `Bearer ${sessionToken}`); + + expect(keyResponse.status).toBe(200); + expect(keyResponse.body.collections).toHaveLength(4); + expect(keyResponse.body).toEqual(sessionResponse.body); + }); + + it('should refuse with 403 origin_not_allowed when the key does not allow the request origin', async () => { + const response = await request( + makeFullAgentEdge(jest.fn().mockResolvedValue(schema), ['https://ok.com']), + ) + .get(ROUTE) + .set(BFF_KEY_HEADER, RAW_KEY) + .set('Origin', 'https://evil.com'); + + expect(response.status).toBe(403); + expect(response.body.error).toMatchObject({ type: 'origin_not_allowed', status: 403 }); + }); + }); + + describe('when the path or method does not match', () => { + it('should pass through to the next middleware', async () => { + const fetchSchema = jest.fn().mockResolvedValue(schema); + const { app } = makeRouteOnlyApp(fetchSchema); + app.use(async ctx => { + ctx.status = 418; + }); + + await expect(request(app.callback()).post(ROUTE)).resolves.toMatchObject({ status: 418 }); + await expect(request(app.callback()).get('/agent/v1/permissions')).resolves.toMatchObject({ + status: 418, + }); + }); + }); +}); diff --git a/packages/agent-bff/test/context/fixtures.ts b/packages/agent-bff/test/context/fixtures.ts new file mode 100644 index 0000000000..a0f5c3f1ff --- /dev/null +++ b/packages/agent-bff/test/context/fixtures.ts @@ -0,0 +1,141 @@ +import type { + ForestSchemaAction, + ForestSchemaCollection, + ForestSchemaField, +} from '@forestadmin/forestadmin-client'; + +type WireField = Partial> & { + enums?: string[] | null; + validations?: unknown; +}; + +type WireActionField = ForestSchemaAction['fields'][number] | null; + +function field(name: string, type: unknown, extra: WireField = {}) { + return { + field: name, + type, + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: name === 'id', + ...extra, + } as ForestSchemaField; +} + +function action( + name: string, + type: ForestSchemaAction['type'], + endpoint: string | undefined, + fields: WireActionField[] = [], +) { + return { + id: `${name}-id`, + name, + type, + endpoint, + download: false, + fields, + hooks: { load: false, change: [] }, + } as ForestSchemaAction; +} + +export default function schemaCoveringEveryContractShape(): ForestSchemaCollection[] { + return [ + { + name: 'users', + fields: [ + field('id', 'Uuid'), + field('tagsWithArrayType', ['String']), + field('matrixWithNestedArrayType', [['String']]), + field('addressesWithArrayOfComposite', [{ fields: [{ field: 'label', type: 'String' }] }]), + field('addressWithCompositeType', { + fields: [ + { field: 'city', type: 'String' }, + { field: 'country', type: 'Enum', enums: ['FR', 'BE'] }, + { field: 'tags', type: ['String'] }, + ], + }), + field('lockedReadOnly', 'Boolean', { isReadOnly: true }), + field('emailRequired', 'String', { isRequired: true }), + field('thumbnailWithPattern', 'String', { + validations: [ + { + type: 'is like', + value: '/^data:.*;base64,.*/', + message: 'Value must match /^data:.*;base64,.*/', + }, + ], + }), + field('statusWithEnums', 'Enum', { + enums: ['DRAFT', 'PUBLISHED'], + }), + field('statusWithFlaggedPattern', 'Enum', { + validations: [{ type: 'is like', value: '/^a|b|c$/g', message: 'x' }], + }), + field('titleWithPresence', 'String', { + validations: [{ type: 'is present', message: 'Field is required' }], + }), + field('fieldWithMalformedValidations', 'String', { + validations: [ + null, + { message: 'no type at all' }, + 'garbage', + { type: 'contains', value: 'ok' }, + ], + }), + field('fieldWithNullValidations', 'String', { + validations: null, + }), + field('ordersHasManyWithInverseOf', 'String', { + reference: 'orders.customerId', + relationship: 'HasMany', + inverseOf: 'customer', + }), + field('teamBelongsToWithoutInverseOf', 'String', { + reference: 'teams.id', + relationship: 'BelongsTo', + }), + field('addressBelongsToDottedCollection', 'String', { + reference: 'User.address.city', + relationship: 'BelongsTo', + }), + field('ownerPolymorphic', 'String', { + relationship: 'BelongsTo', + polymorphicReferencedModels: ['users', 'teams'], + }), + ], + actions: [ + action('Ban user', 'single', '/forest/users/actions/ban', [ + { + field: 'reason', + type: 'Enum', + isRequired: true, + defaultValue: 'spam', + enums: ['spam', 'abuse'], + }, + ]), + action('Export all', 'global', '/forest/users/actions/export', [ + { field: 'includeArchived', type: 'Boolean', isRequired: false, defaultValue: false }, + { field: 'limit', type: 'Number', defaultValue: 0 }, + { field: 'since', type: 'Date', defaultValue: null }, + { field: 'format', type: 'Enum', enums: [] }, + { + field: 'loading', + type: 'String', + enums: null, + }, + ]), + action('Archive', 'bulk', '/forest/users/actions/archive', [ + null, + { field: 'confirm', type: 'Boolean' }, + ]), + action('Endpointless action', 'single', undefined), + ], + }, + { name: 'User.address', fields: [field('city', 'String')], actions: [] }, + { name: 'My Coll', fields: [field('id', 'Number')], actions: [] }, + { name: 'collectionWithoutFieldsNorActions' } as ForestSchemaCollection, + ]; +} diff --git a/packages/agent-bff/test/openapi/openapi-cli.test.ts b/packages/agent-bff/test/openapi/openapi-cli.test.ts index d12f4e8542..32b21ed473 100644 --- a/packages/agent-bff/test/openapi/openapi-cli.test.ts +++ b/packages/agent-bff/test/openapi/openapi-cli.test.ts @@ -93,7 +93,7 @@ describe('renderOpenApi', () => { it('should emit the generic document when nothing is configured to unfold against', async () => { const document = JSON.parse(await renderOpenApi({}, noopLogger)); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); expect(document.info.description).toContain('Paths are generic'); }); @@ -101,6 +101,7 @@ describe('renderOpenApi', () => { const document = JSON.parse(await renderOpenApi(VALID_ENV, noopLogger)); expect(Object.keys(document.paths).sort()).toEqual([ + '/agent/v1/context', '/agent/v1/orders/count', '/agent/v1/orders/list', '/agent/v1/users/actions/Mark%20as%20paid/execute', @@ -145,14 +146,14 @@ describe('renderOpenApi', () => { await renderOpenApi({ ...VALID_ENV, AGENT_URL: undefined }, noopLogger), ); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); expect(fetchSchema).not.toHaveBeenCalled(); }); it('should ignore a broken server-only setting, which the export does not use', async () => { const document = JSON.parse(await renderOpenApi({ HTTP_PORT: 'nope' }, noopLogger)); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); }); it('should still reject a broken setting once the deployment asks to be unfolded', async () => { @@ -230,7 +231,7 @@ describe('dispatchCli', () => { const document = JSON.parse(stdout.mock.calls[0][0] as string); - expect(Object.keys(document.paths)).toHaveLength(6); + expect(Object.keys(document.paths)).toHaveLength(7); } finally { stdout.mockRestore(); } diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index aa8993306c..401c134705 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -22,8 +22,11 @@ const responseComponents = (document.components?.responses ?? {}) as unknown as >; function responsesOf(path: string): Record { - const { responses } = (document.paths?.[path] as { post: { responses: Record } }) - .post; + const item = document.paths?.[path] as { + post?: { responses: Record }; + get?: { responses: Record }; + }; + const { responses } = (item.post ?? item.get) as { responses: Record }; return Object.fromEntries( Object.entries(responses).map(([status, response]) => { @@ -41,6 +44,14 @@ function listResponses(): Record { return responsesOf(`${ROUTE_PREFIX}/{collection}/list`); } +function dataOperations(): { security: unknown; responses: Record }[] { + return Object.values(document.paths ?? {}) + .map( + path => (path as { post?: { security: unknown; responses: Record } }).post, + ) + .filter(operation => operation !== undefined); +} + function leafOperators(): string[] { const leaf = schemas.ConditionTreeLeaf as { properties: { operator: { enum: string[] } }; @@ -58,12 +69,13 @@ describe('generateOpenApiDocument', () => { it('should serve every path under the /agent/v1 prefix', () => { const paths = Object.keys(document.paths ?? {}); - expect(paths).toHaveLength(6); + expect(paths).toHaveLength(7); expect(paths.every(path => path.startsWith(`${ROUTE_PREFIX}/`))).toBe(true); }); - it('should expose the six generic runtime routes', () => { + it('should expose the six generic runtime routes plus the context contract', () => { expect(Object.keys(document.paths ?? {}).sort()).toEqual([ + `${ROUTE_PREFIX}/context`, `${ROUTE_PREFIX}/{collection}/actions/{action}/execute`, `${ROUTE_PREFIX}/{collection}/actions/{action}/form`, `${ROUTE_PREFIX}/{collection}/count`, @@ -148,10 +160,8 @@ describe('generateOpenApiDocument', () => { }); }); - it('should secure every operation with the API key only, the one mode the routes accept', () => { - const operations = Object.values(document.paths ?? {}).map( - path => (path as { post: { security: unknown } }).post, - ); + it('should secure every data operation with the API key only, the one mode those routes accept', () => { + const operations = dataOperations(); expect(operations).toHaveLength(6); operations.forEach(operation => { @@ -159,21 +169,32 @@ describe('generateOpenApiDocument', () => { }); }); - it('should say in the session scheme why no operation accepts it yet', () => { + it('should let both auth modes reach the context contract, which is not caller-scoped', () => { + const context = (document.paths ?? {})[`${ROUTE_PREFIX}/context`] as { + get: { security: unknown }; + }; + + expect(context.get.security).toEqual([{ bffSession: [] }, { bffApiKey: [] }]); + }); + + it('should say in the session scheme which routes accept it', () => { const session = ( document.components?.securitySchemes as Record ).bffSession; - expect(session.description).toContain('reject it until'); + expect(session.description).toContain('Accepted on the context contract'); + expect(session.description).toContain('the data and action routes advertise the API key only'); }); it('should require a body where parentId or recordIds is mandatory', () => { const requiredByPath = Object.fromEntries( - Object.entries(document.paths ?? {}).map(([path, item]) => [ - path, - (item as { post: { requestBody: { required?: boolean } } }).post.requestBody.required === - true, - ]), + Object.entries(document.paths ?? {}) + .filter(([, item]) => (item as { post?: unknown }).post !== undefined) + .map(([path, item]) => [ + path, + (item as { post: { requestBody: { required?: boolean } } }).post.requestBody.required === + true, + ]), ); expect(requiredByPath).toEqual({ @@ -209,26 +230,20 @@ describe('generateOpenApiDocument', () => { }); it('should document 502, which any agent transport failure returns', () => { - Object.values(document.paths ?? {}).forEach(path => { - expect( - Object.keys((path as { post: { responses: Record } }).post.responses), - ).toContain('502'); + dataOperations().forEach(operation => { + expect(Object.keys(operation.responses)).toContain('502'); }); }); it('should document 413, since the BFF caps the body at 16kb', () => { - Object.values(document.paths ?? {}).forEach(path => { - expect( - Object.keys((path as { post: { responses: Record } }).post.responses), - ).toContain('413'); + dataOperations().forEach(operation => { + expect(Object.keys(operation.responses)).toContain('413'); }); }); it('should declare 501 everywhere, since the agent stub returns it on every route', () => { - Object.values(document.paths ?? {}).forEach(path => { - expect( - Object.keys((path as { post: { responses: Record } }).post.responses), - ).toContain('501'); + dataOperations().forEach(operation => { + expect(Object.keys(operation.responses)).toContain('501'); }); }); @@ -254,10 +269,8 @@ describe('generateOpenApiDocument', () => { }); it('should share one response component per error status instead of inlining it per path', () => { - const errors = Object.values(document.paths ?? {}).flatMap(path => - Object.entries( - (path as { post: { responses: Record } }).post.responses, - ).filter(([status]) => status !== '200'), + const errors = dataOperations().flatMap(operation => + Object.entries(operation.responses).filter(([status]) => status !== '200'), ); expect(errors).toHaveLength(72); @@ -296,7 +309,7 @@ describe('generateOpenApiDocument', () => { it('should share the timezone header as one parameter component instead of per path', () => { const parameters = Object.values(document.paths ?? {}).flatMap( - path => (path as { post: { parameters: unknown[] } }).post.parameters, + path => (path as { post?: { parameters: unknown[] } }).post?.parameters ?? [], ); expect(parameters).toContainEqual({ $ref: '#/components/parameters/XForestTimezone' }); diff --git a/packages/agent-bff/test/openapi/openapi-routes.test.ts b/packages/agent-bff/test/openapi/openapi-routes.test.ts index e2b35cc02f..d7a6c36420 100644 --- a/packages/agent-bff/test/openapi/openapi-routes.test.ts +++ b/packages/agent-bff/test/openapi/openapi-routes.test.ts @@ -143,6 +143,7 @@ describe('GET /agent/openapi.json', () => { expect(response.status).toBe(200); expect(response.body.openapi).toBe('3.1.0'); expect(Object.keys(response.body.paths).sort()).toEqual([ + '/agent/v1/context', '/agent/v1/orders/count', '/agent/v1/orders/list', '/agent/v1/users/actions/Mark%20as%20paid/execute', @@ -206,7 +207,7 @@ describe('GET /agent/openapi.json', () => { expect(withKey.status).toBe(200); expect(withKey.body.openapi).toBe('3.1.0'); - expect(Object.keys(withKey.body.paths)).toHaveLength(8); + expect(Object.keys(withKey.body.paths)).toHaveLength(9); expect(Object.keys(withKey.body.paths).sort()).toEqual( Object.keys(withSession.body.paths).sort(), ); @@ -233,7 +234,7 @@ describe('GET /agent/openapi.json', () => { .set('Authorization', `Bearer ${sessionToken()}`); expect(response.status).toBe(200); - expect(Object.keys(response.body.paths)).toHaveLength(6); + expect(Object.keys(response.body.paths)).toHaveLength(7); expect(response.body.info.description).toContain('Paths are generic'); expect(fetchSchema).not.toHaveBeenCalled(); }); @@ -392,6 +393,7 @@ describe('GET /agent/openapi.json', () => { await routesFor(store)(ctx, async () => undefined); expect(Object.keys(JSON.parse(ctx.body as string).paths)).toEqual([ + '/agent/v1/context', '/agent/v1/orders/list', '/agent/v1/orders/count', ]); diff --git a/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts b/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts index 9673ec78b4..00606078d8 100644 --- a/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts +++ b/packages/agent-bff/test/openapi/openapi-spec-validity.test.ts @@ -42,9 +42,8 @@ describe.each([ }); }); - it('should warn only that the session scheme is unused, which is deliberate', () => { - expect(output).toContain('You have 1 warning'); - expect(output).toContain('bffSession" is never used'); + it('should lint clean, now that the context contract uses the session scheme', () => { + expect(output).not.toMatch(/You have \d+ warning/); expect(output).not.toMatch(/You have \d+ error/); }); }); diff --git a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts index 290e50560f..5a9f7fb3bb 100644 --- a/packages/agent-bff/test/openapi/openapi-unfolded.test.ts +++ b/packages/agent-bff/test/openapi/openapi-unfolded.test.ts @@ -10,7 +10,9 @@ import unfoldingFixture from './fixtures'; import { ROUTE_PREFIX, generateOpenApiDocument } from '../../src/openapi/openapi-document'; const document = generateOpenApiDocument('9.9.9', unfoldingFixture()); -const paths = document.paths as Record }>; +const paths = Object.fromEntries( + Object.entries(document.paths ?? {}).filter(([path]) => path !== `${ROUTE_PREFIX}/context`), +) as Record }>; const schemas = document.components?.schemas as Record>; function operation(path: string): Record { @@ -434,6 +436,7 @@ describe('an unfolding naming a collection it does not carry', () => { }); expect(Object.keys(orphaned.paths ?? {})).toEqual([ + '/agent/v1/context', '/agent/v1/users/list', '/agent/v1/users/count', ]); @@ -464,8 +467,10 @@ describe('names that collide once sanitized', () => { collections: [collection('A_B', 'C', 'R'), collection('A', 'B_C', 'B_R')], }); const operationIds = Object.values( - colliding.paths as Record, - ).map(item => item.post.operationId); + colliding.paths as Record, + ) + .filter(item => item.post !== undefined) + .map(item => (item.post as { operationId: string }).operationId); it('should keep every operationId unique, which codegen tools require', () => { expect(new Set(operationIds).size).toBe(operationIds.length); diff --git a/packages/agent-bff/test/read-model/read-model-store.test.ts b/packages/agent-bff/test/read-model/read-model-store.test.ts index 300e2ad9b5..5fa5b90d5f 100644 --- a/packages/agent-bff/test/read-model/read-model-store.test.ts +++ b/packages/agent-bff/test/read-model/read-model-store.test.ts @@ -9,14 +9,14 @@ describe('ReadModelStore', () => { let clock: number; const now = () => clock; - function build(fetchSchema: jest.Mock): ReadModelStore { + function build(fetchSchema: jest.Mock, clockOverride: () => number = now): ReadModelStore { const metrics = makeMetrics(); const schemaCache = new SchemaCache({ fetcher: { fetchSchema } as SchemaFetcher, metrics, - now, + now: clockOverride, }); - const capabilitiesCache = new CapabilitiesCache({ now }); + const capabilitiesCache = new CapabilitiesCache({ now: clockOverride }); return new ReadModelStore(schemaCache, capabilitiesCache); } @@ -25,6 +25,80 @@ describe('ReadModelStore', () => { clock = 1_000_000; }); + describe('getSchemaSnapshot', () => { + it('should return a read-model derived from the very collections it returns', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + + const { collections, readModel, revision } = await store.getSchemaSnapshot(); + + expect(collections.map(entry => entry.name)).toEqual(['users']); + expect(readModel.getAllowedCollections()).toEqual(collections.map(entry => entry.name)); + expect(revision).toBe(1); + }); + + it('should read the schema once, so the read-model cannot come from a later generation', async () => { + let generation = 0; + + const fetchSchema = jest.fn().mockImplementation(async () => { + generation += 1; + + return makeSchema(`generation-${generation}`); + }); + + const alwaysExpiredClock = () => { + clock += ONE_DAY_MS + 1; + + return clock; + }; + + const store = build(fetchSchema, alwaysExpiredClock); + + const { collections, readModel, revision } = await store.getSchemaSnapshot(); + + expect(fetchSchema).toHaveBeenCalledTimes(1); + expect(collections.map(entry => entry.name)).toEqual(['generation-1']); + expect(readModel.getAllowedCollections()).toEqual(['generation-1']); + expect(revision).toBe(1); + }); + + it('should read the cache revision once, so the triple cannot straddle two generations', async () => { + const store = build(jest.fn().mockResolvedValue(makeSchema('users'))); + const cache = Reflect.get(store, 'schemaCache') as SchemaCache; + let reads = 0; + let bumped = 0; + + jest.spyOn(cache, 'revision', 'get').mockImplementation(() => { + reads += 1; + bumped += 1; + + return bumped; + }); + + const { revision } = await store.getSchemaSnapshot(); + + expect(reads).toBe(1); + expect(revision).toBe(1); + }); + + it('should not label one generation of collections with another generation revision', async () => { + const fetchSchema = jest + .fn() + .mockResolvedValueOnce(makeSchema('first')) + .mockResolvedValueOnce(makeSchema('second')); + const store = build(fetchSchema); + + const first = await store.getSchemaSnapshot(); + clock += ONE_DAY_MS + 1; + const second = await store.getSchemaSnapshot(); + + expect(first.collections.map(entry => entry.name)).toEqual(['first']); + expect(first.readModel.getAllowedCollections()).toEqual(['first']); + expect(second.collections.map(entry => entry.name)).toEqual(['second']); + expect(second.readModel.getAllowedCollections()).toEqual(['second']); + expect(second.revision).toBeGreaterThan(first.revision); + }); + }); + describe('getReadModel', () => { it('should build the read-model from the fetched schema', async () => { const store = build(jest.fn().mockResolvedValue(makeSchema('users')));