diff --git a/configs/camunda-oca/regression-invariants.test.ts b/configs/camunda-oca/regression-invariants.test.ts index 7ef1a45..87edc03 100644 --- a/configs/camunda-oca/regression-invariants.test.ts +++ b/configs/camunda-oca/regression-invariants.test.ts @@ -8865,3 +8865,221 @@ describe.skipIf(CONFIG_NAME !== ACTIVE_CONFIG)( }); }, ); + +// --------------------------------------------------------------------------- +// Enum-typed request-body field seeding (#338) +// --------------------------------------------------------------------------- +// +// The planner previously discarded enum constraints on required request-body +// fields: even though the extractor captured them, `buildRequestBodyFromCanonical` +// fell through to the seedBinding fallback and emitted `${fieldVar}` placeholders +// the universal seed prologue rewrote to random short strings. Servers reject +// those with HTTP 400 "Value is not a valid ." Same for arrays +// whose items are enums (e.g. `permissionTypes: array of PermissionTypeEnum`): +// the synthesised element was a literal `"placeholder"` string the server +// rejected. +// +// This invariant is class-scoped (all required enum-typed request-body fields +// across the bundled spec — not just `ownerType` / `permissionTypes` from +// `createAuthorization` that triggered the bug report) so the same category +// of bug cannot recur in a sibling op. +describeForThisConfig( + 'bundled-spec invariants: enum-typed request-body field seeding (#338)', + () => { + it('no feature or variant scenario seeds a ${...} placeholder or a "placeholder" array item for a required enum-typed request-body field', () => { + if (!existsSync(FEATURE_SCENARIOS_DIR)) { + throw new Error( + `Feature output directory not found at ${FEATURE_SCENARIOS_DIR}. Run 'npm run pipeline' first.`, + ); + } + if (!existsSync(BUNDLED_SPEC_PATH)) { + throw new Error( + `Bundled spec not found at ${BUNDLED_SPEC_PATH}. Run 'npm run fetch-spec' first.`, + ); + } + + interface SchemaObject { + type?: string; + $ref?: string; + required?: string[]; + properties?: Record; + items?: SchemaObject; + oneOf?: SchemaObject[]; + allOf?: SchemaObject[]; + enum?: unknown[]; + } + + interface OpenApiSpec { + paths: Record< + string, + Record< + string, + { + operationId?: string; + requestBody?: { content?: { 'application/json'?: { schema?: SchemaObject } } }; + } + > + >; + components?: { schemas?: Record }; + } + + // biome-ignore lint/plugin: runtime contract boundary for parsed JSON + const spec = JSON.parse(readFileSync(BUNDLED_SPEC_PATH, 'utf8')) as OpenApiSpec; + + function resolveRef(ref: string): SchemaObject | undefined { + const name = ref.split('/').pop() ?? ''; + return spec.components?.schemas?.[name]; + } + + function resolveSchemaObj(s: SchemaObject, depth = 0): SchemaObject { + if (depth > 20) return s; + if (s.$ref) { + const target = resolveRef(s.$ref); + if (target) return resolveSchemaObj(target, depth + 1); + } + return s; + } + + /** Walk $ref + allOf chain to find an enum array. */ + function effectiveEnum(s: SchemaObject, depth = 0): unknown[] | undefined { + if (depth > 20) return undefined; + const r = resolveSchemaObj(s, depth); + if (Array.isArray(r.enum)) return r.enum; + if (Array.isArray(r.allOf)) { + for (const part of r.allOf) { + const e = effectiveEnum(part, depth + 1); + if (e) return e; + } + } + return undefined; + } + + /** + * Walk a request-body schema (root + any oneOf variants) and collect, + * for every top-level required field, the enum values declared on the + * field's scalar schema (`fieldEnums`) and on its items' scalar schema + * when the field is an array (`itemEnums`). + */ + function collectEnumFields(rootSchema: SchemaObject): { + fieldEnums: Map; + itemEnums: Map; + } { + const fieldEnums = new Map(); + const itemEnums = new Map(); + const branches: SchemaObject[] = []; + const root = resolveSchemaObj(rootSchema); + if (root.properties) branches.push(root); + for (const v of root.oneOf ?? []) branches.push(resolveSchemaObj(v)); + for (const branch of branches) { + const required = new Set(branch.required ?? []); + for (const [field, sub] of Object.entries(branch.properties ?? {})) { + if (!required.has(field)) continue; + const fieldEnum = effectiveEnum(sub); + if (fieldEnum && fieldEnum.length > 0 && !fieldEnums.has(field)) { + fieldEnums.set(field, fieldEnum); + } + const resolved = resolveSchemaObj(sub); + if (resolved.type === 'array' && resolved.items) { + const itemEnum = effectiveEnum(resolved.items); + if (itemEnum && itemEnum.length > 0 && !itemEnums.has(field)) { + itemEnums.set(field, itemEnum); + } + } + } + } + return { fieldEnums, itemEnums }; + } + + // Build per-op map: opId -> { fieldEnums, itemEnums } + const enumFieldsByOp = new Map< + string, + { fieldEnums: Map; itemEnums: Map } + >(); + for (const pathItem of Object.values(spec.paths ?? {})) { + for (const op of Object.values(pathItem)) { + if (!op.operationId) continue; + const jsonBody = op.requestBody?.content?.['application/json']?.schema; + if (!jsonBody) continue; + const collected = collectEnumFields(jsonBody); + if (collected.fieldEnums.size > 0 || collected.itemEnums.size > 0) { + enumFieldsByOp.set(op.operationId, collected); + } + } + } + + interface FeatureScenarioFile { + scenarios: { + id: string; + requestPlan?: { operationId: string; bodyTemplate?: Record }[]; + }[]; + } + + const offenders: { + file: string; + scenario: string; + operationId: string; + field: string; + value: string; + expectedEnum: unknown[]; + }[] = []; + const placeholderPattern = /^\$\{[^}]+\}$/; + + for (const dir of [FEATURE_SCENARIOS_DIR, VARIANT_SCENARIOS_DIR]) { + if (!existsSync(dir)) continue; + for (const f of readdirSync(dir)) { + if (!f.endsWith('-scenarios.json')) continue; + // biome-ignore lint/plugin: runtime contract boundary for parsed JSON + const file = JSON.parse(readFileSync(join(dir, f), 'utf8')) as FeatureScenarioFile; + for (const scenario of file.scenarios ?? []) { + for (const step of scenario.requestPlan ?? []) { + const enums = enumFieldsByOp.get(step.operationId); + if (!enums) continue; + for (const [field, value] of Object.entries(step.bodyTemplate ?? {})) { + // Scalar enum field: a string that looks like ${var} is a placeholder seed. + const fieldEnum = enums.fieldEnums.get(field); + if (fieldEnum && typeof value === 'string' && placeholderPattern.test(value)) { + offenders.push({ + file: f, + scenario: scenario.id, + operationId: step.operationId, + field, + value, + expectedEnum: fieldEnum, + }); + continue; + } + // Array enum field: any element that's a ${var} placeholder OR + // the generic synthesised "placeholder" literal is wrong. + const itemEnum = enums.itemEnums.get(field); + if (itemEnum && Array.isArray(value)) { + for (const elem of value) { + const isPlaceholderVar = + typeof elem === 'string' && placeholderPattern.test(elem); + const isGenericPlaceholder = elem === 'placeholder'; + if (isPlaceholderVar || isGenericPlaceholder) { + offenders.push({ + file: f, + scenario: scenario.id, + operationId: step.operationId, + field, + value: JSON.stringify(elem), + expectedEnum: itemEnum, + }); + } + } + } + } + } + } + } + } + + expect( + offenders, + 'A scenario bodyTemplate seeds a placeholder for a required enum-typed request-body field. ' + + 'The planner must emit the first enum value as an inline literal instead of ${var} or "placeholder". ' + + 'Server validation rejects unknown enum values with HTTP 400 (#338).', + ).toEqual([]); + }); + }, +); diff --git a/path-analyser/src/canonicalSchemas.ts b/path-analyser/src/canonicalSchemas.ts index 34f073e..f12cc0b 100644 --- a/path-analyser/src/canonicalSchemas.ts +++ b/path-analyser/src/canonicalSchemas.ts @@ -9,6 +9,22 @@ export interface CanonicalNodeMeta { type: string; required: boolean; semanticProvider?: string; // semantic type if x-semantic-provider: true + /** + * Enum values declared on this field's schema (after $ref + allOf + * resolution). Present for scalar leaf nodes whose schema constrains + * the value to a fixed set. Used by `buildRequestBodyFromCanonical` + * to emit an enum literal instead of seeding a `${var}` placeholder + * (#338). + */ + enum?: unknown[]; + /** + * Enum values declared on this array node's item schema. Present for + * top-level array fields whose items are scalar enums (e.g. + * `permissionTypes: { type: 'array', items: { enum: […] } }`). Used + * by the array synthesiser to emit `[enum[0]]` instead of the generic + * `['placeholder']` element (#338). + */ + itemEnum?: unknown[]; } export interface OperationCanonicalShapes { @@ -29,6 +45,7 @@ interface SchemaObject { anyOf?: SchemaObject[]; oneOf?: SchemaObject[]; 'x-semantic-provider'?: boolean; + enum?: unknown[]; // Permissive escape hatch so the allOf merger can copy over arbitrary // OpenAPI keywords (description, example, format, …) without per-key // typing or unsafe casts. @@ -178,6 +195,13 @@ function walkSchema( if (type === 'array' && schema.items) { const childPath = `${pathSoFar}[]`; const childPointer = `${pointer}/items`; + // Capture an item-level enum (e.g. `permissionTypes: { type: 'array', + // items: { enum: […] } }`) so the array synthesiser can emit + // `[enum[0]]` instead of the generic `['placeholder']` element + // (#338). `schema.items` may itself be a $ref / allOf, so resolve + // it before reading `.enum`. + const resolvedItems = resolveSchema(schema.items, components); + const itemEnum = Array.isArray(resolvedItems.enum) ? resolvedItems.enum : undefined; acc.push({ path: `${pathSoFar}[]`, pointer: childPointer, @@ -186,6 +210,7 @@ function walkSchema( semanticProvider: schema['x-semantic-provider'] ? inferSemanticTypeFromPath(pathSoFar) : undefined, + itemEnum, }); walkSchema(schema.items, childPointer, childPath, acc, seen, components, false, depth + 1); return; @@ -194,7 +219,19 @@ function walkSchema( const semantic = schema['x-semantic-provider'] ? inferSemanticTypeFromPath(pathSoFar) : undefined; - acc.push({ path: pathSoFar, pointer, type, required, semanticProvider: semantic }); + // Capture leaf enum (e.g. `ownerType: { enum: ['USER', …] }`) so + // `buildRequestBodyFromCanonical` can emit an enum literal instead + // of seeding a `${var}` placeholder (#338). `schema` at this point + // is already $ref/allOf-resolved by the recursive descent above. + const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined; + acc.push({ + path: pathSoFar, + pointer, + type, + required, + semanticProvider: semantic, + enum: enumValues, + }); } } diff --git a/path-analyser/src/extractSchemas.ts b/path-analyser/src/extractSchemas.ts index 1fb82bd..094e9bc 100644 --- a/path-analyser/src/extractSchemas.ts +++ b/path-analyser/src/extractSchemas.ts @@ -31,6 +31,7 @@ interface JsonSchema { nullable?: boolean; discriminator?: { propertyName?: string; mapping?: Record }; 'x-polymorphic-schema'?: boolean; + enum?: unknown[]; [key: string]: unknown; } @@ -269,9 +270,20 @@ function findOneOfGroups( const required = vs.required || []; const optional = Object.keys(props).filter((k) => !required.includes(k)); const fieldTypes: Record = {}; + const fieldEnums: Record = {}; + const fieldItemEnums: Record = {}; for (const [fname, fsch] of Object.entries(props)) { const ft = effectiveType(fsch, components); if (ft && ft !== 'unknown') fieldTypes[fname] = ft; + const enumValues = effectiveEnum(fsch, components); + if (enumValues && enumValues.length > 0) fieldEnums[fname] = enumValues; + if (ft === 'array') { + const itemSchema = resolveSchema(fsch, components).items; + if (itemSchema) { + const itemEnum = effectiveEnum(itemSchema, components); + if (itemEnum && itemEnum.length > 0) fieldItemEnums[fname] = itemEnum; + } + } } let discriminator: { field: string; value: string } | undefined; if (resolved.discriminator?.propertyName) { @@ -289,6 +301,8 @@ function findOneOfGroups( required, optional, fieldTypes, + fieldEnums: Object.keys(fieldEnums).length > 0 ? fieldEnums : undefined, + fieldItemEnums: Object.keys(fieldItemEnums).length > 0 ? fieldItemEnums : undefined, discriminator, }; }); @@ -369,6 +383,36 @@ function effectiveType(schema: JsonSchema, components: Components): string { return 'unknown'; } +/** + * Walk a (possibly $ref- or allOf-wrapped) schema and return its `enum` + * array if one is declared. Used by `findOneOfGroups` and `walkSchema` to + * surface enum constraints to the planner so it can emit a real enum + * literal instead of seeding a `${var}` placeholder (#338). + * + * Returns `undefined` when no enum is declared anywhere along the chain. + * The result is the raw `enum` array — callers pick the first value. + */ +function effectiveEnum( + schema: JsonSchema | undefined, + components: Components, + depth = 0, +): unknown[] | undefined { + if (!schema || depth > 10) return undefined; + if (Array.isArray(schema.enum)) return schema.enum; + if (schema.$ref) { + const target = components[refName(schema.$ref)]; + const e = effectiveEnum(target, components, depth + 1); + if (e) return e; + } + if (Array.isArray(schema.allOf)) { + for (const part of schema.allOf) { + const e = effectiveEnum(part, components, depth + 1); + if (e) return e; + } + } + return undefined; +} + function refName(ref: string): string { return ref.split('/').pop() || ref; } diff --git a/path-analyser/src/index.ts b/path-analyser/src/index.ts index eafa185..add95ee 100644 --- a/path-analyser/src/index.ts +++ b/path-analyser/src/index.ts @@ -1047,7 +1047,10 @@ function camelCase(name: string) { } type CanonicalShape = { - requestByMediaType?: Record; + requestByMediaType?: Record< + string, + { path: string; type: string; required: boolean; enum?: unknown[]; itemEnum?: unknown[] }[] + >; }; type RequestBodyPlan = @@ -1091,13 +1094,20 @@ type RequestBodyPlan = * that themselves declare required content); * - otherwise it seeds a string `'placeholder'`. * - * Item-schema enums could in principle be sourced from the bundled - * spec for stricter validity, but the canonical nodes don't carry - * enum metadata today; the L3 invariant for #326 only requires that - * the emitted value is not literally `[]`, and the broader live-cluster - * acceptance is tracked separately. + * Item-schema enums are sourced from the bundled spec (#338): top-level + * array nodes carry an `itemEnum` field captured during canonical-shape + * walking, and `buildRequestBodyFromCanonical` short-circuits to + * `[itemEnum[0]]` before delegating to this helper. The helper itself + * remains enum-unaware for deeper nested arrays — those still seed a + * `'placeholder'` element, tracked separately. */ -type CanonicalNode = { path: string; type: string; required: boolean }; +type CanonicalNode = { + path: string; + type: string; + required: boolean; + enum?: unknown[]; + itemEnum?: unknown[]; +}; function synthesizeObjectFromPrefix( prefix: string, @@ -1283,6 +1293,12 @@ function buildRequestBodyFromCanonical( template[name] = `${'${'}${varName}}`; } else if (defaults && Object.hasOwn(defaults, name)) { template[name] = defaults[name]; + } else if (chosenVariant?.fieldEnums?.[name]?.length) { + // #338 — schema declares an enum for this required field. + // Emit the first enum literal instead of seeding a + // `${var}` placeholder that the server rejects with 400. + const values = chosenVariant.fieldEnums[name]; + template[name] = values[0]; } else if ((declaredTypeByLeaf[name] ?? chosenVariant?.fieldTypes?.[name]) === 'object') { // Object-typed required field with no binding: emit {} rather than seeding // a string placeholder. An empty object is always a valid JSON value and @@ -1292,7 +1308,16 @@ function buildRequestBodyFromCanonical( // #326 — emit a synthesised one-element array honouring the // item schema's own required set rather than `[]`, which // servers reject ("No elements provided" etc.). - template[name] = [synthesizeArrayElement(name, nodes)]; + // #338 — when the array's item schema declares an enum + // (e.g. `permissionTypes: array of PermissionTypeEnum`), + // emit `[enum[0]]` instead of the generic placeholder + // element so the request validates server-side. + const itemEnum = chosenVariant?.fieldItemEnums?.[name]; + if (itemEnum?.length) { + template[name] = [itemEnum[0]]; + } else { + template[name] = [synthesizeArrayElement(name, nodes)]; + } } else { scenario.bindings ||= {}; if (!scenario.bindings[varName]) scenario.bindings[varName] = PENDING_BINDING; @@ -1330,6 +1355,11 @@ function buildRequestBodyFromCanonical( template[leaf] = `${'${'}${varName}}`; } else if (defaults && Object.hasOwn(defaults, leaf)) { template[leaf] = defaults[leaf]; + } else if (f.enum?.length) { + // #338 — schema declares an enum for this required scalar + // field. Emit the first enum literal instead of seeding a + // `${var}` placeholder that the server rejects with 400. + template[leaf] = f.enum[0]; } else if (f.type === 'object') { // Object-typed required field with no binding: emit {} rather than seeding // a string placeholder. An empty object is always a valid JSON value and @@ -1340,8 +1370,16 @@ function buildRequestBodyFromCanonical( // recorded as `[]` for top-level arrays; strip the // suffix so the helper's prefix match (`[].`) // lines up with the canonical sub-nodes. - const basePath = f.path.replace(/\[\]$/, ''); - template[leaf] = [synthesizeArrayElement(basePath, nodes)]; + // #338 — when the array's item schema declares an enum + // (captured on the canonical node as `itemEnum`), emit + // `[itemEnum[0]]` instead of synthesising a placeholder + // element. Servers reject `["placeholder"]` with 400. + if (f.itemEnum?.length) { + template[leaf] = [f.itemEnum[0]]; + } else { + const basePath = f.path.replace(/\[\]$/, ''); + template[leaf] = [synthesizeArrayElement(basePath, nodes)]; + } } else { scenario.bindings ||= {}; if (!scenario.bindings[varName]) scenario.bindings[varName] = PENDING_BINDING; diff --git a/path-analyser/src/types.ts b/path-analyser/src/types.ts index 5e4169d..a2d80cd 100644 --- a/path-analyser/src/types.ts +++ b/path-analyser/src/types.ts @@ -421,6 +421,20 @@ export interface RequestOneOfVariant { optional: string[]; /** Effective JSON Schema type for each field in this variant ('object', 'array', 'string', …). */ fieldTypes: Record; + /** + * Enum values for scalar fields in this variant, captured from the + * field's resolved schema (`enum`, traversing $ref + allOf). Used by + * `buildRequestBodyFromCanonical` to emit an enum literal instead of a + * `${var}` placeholder for required enum-typed fields (#338). + */ + fieldEnums?: Record; + /** + * For `array` fields, enum values declared on the item schema (e.g. + * `permissionTypes: { type: 'array', items: { enum: […] } }`). Used to + * synthesise a one-element array containing an enum literal instead of + * the generic `['placeholder']` element (#338). + */ + fieldItemEnums?: Record; discriminator?: { field: string; value: string }; }