From 82f09f4c86329426bdf5935ae428f825063c8a3b Mon Sep 17 00:00:00 2001 From: Marcelo Karval <46399382+marcelokarval@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:10:06 -0300 Subject: [PATCH] fix(api): align combo body and legacy key access (#12070) Alinha o body do combo e o acesso legado por chave, com testes atualizados (CLI api-generator + row parsers). Validado no worktree combinado. Obrigado! --- bin/cli/api-commands/combos.mjs | 15 +++++-- ...g-combo-test-contract-legacy-key-access.md | 1 + docs/openapi.yaml | 15 +++++++ public/openapi.yaml | 15 +++++++ scripts/cli/generate-api-commands.mjs | 5 ++- src/lib/db/apiKeys/rowParsers.ts | 4 ++ tests/unit/apikeys-row-parsers-split.test.ts | 7 ++++ .../unit/cli-api-generator-ref-params.test.ts | 39 ++++++++++++++++--- 8 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/pending-combo-test-contract-legacy-key-access.md diff --git a/bin/cli/api-commands/combos.mjs b/bin/cli/api-commands/combos.mjs index 8f1976be23..ddd5b4d261 100644 --- a/bin/cli/api-commands/combos.mjs +++ b/bin/cli/api-commands/combos.mjs @@ -16,7 +16,7 @@ export function register_combos(parent) { }); tag.command("post-api-combos") .description("Create routing combo") - .option("--body ", "JSON body or @path/to/file.json") + .requiredOption("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos"; @@ -44,7 +44,7 @@ export function register_combos(parent) { tag.command("put-api-combos-id-") .description("Update combo") .requiredOption("--id ", "") - .option("--body ", "JSON body or @path/to/file.json") + .requiredOption("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/{id}"; @@ -62,7 +62,7 @@ export function register_combos(parent) { tag.command("patch-api-combos-id-") .description("Update combo") .requiredOption("--id ", "") - .option("--body ", "JSON body or @path/to/file.json") + .requiredOption("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/{id}"; @@ -99,10 +99,17 @@ export function register_combos(parent) { }); tag.command("post-api-combos-test") .description("Test a combo configuration") + .requiredOption("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/test"; - const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/changelog.d/fixes/pending-combo-test-contract-legacy-key-access.md b/changelog.d/fixes/pending-combo-test-contract-legacy-key-access.md new file mode 100644 index 0000000000..87b5a4623a --- /dev/null +++ b/changelog.d/fixes/pending-combo-test-contract-legacy-key-access.md @@ -0,0 +1 @@ +- **fix(api):** Generated API CLI commands now enforce required OpenAPI request bodies; Combo test commands forward the required `comboName` body, while API keys created by older writers after migration 149 preserve legacy allow-all Combo access without widening explicit empty allowlists — thanks @marcelokarval diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 32b5f42bee..a686ab483d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2350,9 +2350,24 @@ paths: post: tags: [Combos] summary: Test a combo configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [comboName] + properties: + comboName: + type: string + minLength: 1 responses: "200": description: Test result + "400": + description: Missing or invalid combo name + "404": + description: Combo not found /api/settings: get: diff --git a/public/openapi.yaml b/public/openapi.yaml index caa5aac6ea..8e99fcffb4 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1936,9 +1936,24 @@ paths: post: tags: [Combos] summary: Test a combo configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [comboName] + properties: + comboName: + type: string + minLength: 1 responses: "200": description: Test result + "400": + description: Missing or invalid combo name + "404": + description: Combo not found /api/settings: get: diff --git a/scripts/cli/generate-api-commands.mjs b/scripts/cli/generate-api-commands.mjs index b0bbff85f7..1f613ff309 100644 --- a/scripts/cli/generate-api-commands.mjs +++ b/scripts/cli/generate-api-commands.mjs @@ -130,7 +130,10 @@ for (const [tag, ops] of Object.entries(byTag)) { lines.push(` .${flag}("--${kebab(p.name)} <${p.name}>", "${escapeStr(p.description)}")`); } if (hasBody) { - lines.push(` .option("--body ", "JSON body or @path/to/file.json")`); + const bodyFlag = op.requestBody.required ? "requiredOption" : "option"; + lines.push( + ` .${bodyFlag}("--body ", "JSON body or @path/to/file.json")` + ); } lines.push(` .action(async (opts, cmd) => {`); lines.push(` const gOpts = cmd.optsWithGlobals();`); diff --git a/src/lib/db/apiKeys/rowParsers.ts b/src/lib/db/apiKeys/rowParsers.ts index 86be2bd155..1293e0ddea 100644 --- a/src/lib/db/apiKeys/rowParsers.ts +++ b/src/lib/db/apiKeys/rowParsers.ts @@ -9,6 +9,7 @@ */ import type { AccessSchedule, RateLimitRule } from "./types"; +import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess"; export { parseModelAccessMode } from "./modelAccessMode"; export type { ModelAccessMode } from "./modelAccessMode"; @@ -30,6 +31,9 @@ export function parseAllowedModels(value: unknown): string[] { } export function parseAllowedCombos(value: unknown): string[] { + // Migration 149 may already be recorded before an older writer creates a key. + // Preserve those legacy NULL rows as allow-all while keeping explicit [] deny-all. + if (value === null || value === undefined) return [ALL_COMBOS_ACCESS_RULE]; return parseStringList(value); } diff --git a/tests/unit/apikeys-row-parsers-split.test.ts b/tests/unit/apikeys-row-parsers-split.test.ts index 26158f1f28..70180f3c3a 100644 --- a/tests/unit/apikeys-row-parsers-split.test.ts +++ b/tests/unit/apikeys-row-parsers-split.test.ts @@ -39,6 +39,13 @@ test("parseAllowedModels keeps only string entries, tolerates junk", () => { assert.deepEqual(P.parseAllowedModels(null), []); }); +test("parseAllowedCombos preserves legacy NULL as allow-all without widening explicit []", () => { + assert.deepEqual(P.parseAllowedCombos(null), ["combo/*"]); + assert.deepEqual(P.parseAllowedCombos(undefined), ["combo/*"]); + assert.deepEqual(P.parseAllowedCombos("[]"), []); + assert.deepEqual(P.parseAllowedCombos('["fast-chat"]'), ["fast-chat"]); +}); + test("flag parsers honor the 0/1/true/false matrix", () => { assert.equal(P.parseNoLog(1), true); assert.equal(P.parseNoLog("1"), true); diff --git a/tests/unit/cli-api-generator-ref-params.test.ts b/tests/unit/cli-api-generator-ref-params.test.ts index 5f6799e6e5..d019691e78 100644 --- a/tests/unit/cli-api-generator-ref-params.test.ts +++ b/tests/unit/cli-api-generator-ref-params.test.ts @@ -40,6 +40,18 @@ paths: responses: "200": description: Updated widget + /api/widgets/preview: + post: + tags: [Widgets] + summary: Preview widget + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: Previewed widget components: parameters: ResourceId: @@ -83,11 +95,16 @@ test("generator resolves a $ref path parameter into --id and substitutes {id} in ); assert.doesNotMatch(generated, /url = "\/api\/widgets\/\{id\}";\s*\n\s*const res/); - // requestBody presence must still produce --body. + // Required and optional request bodies must preserve their OpenAPI semantics. assert.match( generated, - /\.option\("--body "/, - "generated command must declare --body for the requestBody" + /tag\.command\("patch-api-widgets-id-?"\)[\s\S]*?\.requiredOption\("--body "/, + "generated command must require --body for a required requestBody" + ); + assert.match( + generated, + /tag\.command\("post-api-widgets-preview"\)[\s\S]*?\.option\("--body "/, + "generated command must keep --body optional for an optional requestBody" ); } finally { rmSync(workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); @@ -145,8 +162,8 @@ test("real generated bin/cli/api-commands/combos.mjs has --id and --body on the ); assert.match( patchBlock, - /\.option\("--body "/, - "PATCH combo command must accept --body" + /\.requiredOption\("--body "/, + "PATCH combo command must require --body" ); assert.match( patchBlock, @@ -154,3 +171,15 @@ test("real generated bin/cli/api-commands/combos.mjs has --id and --body on the "PATCH combo command must substitute {id} in the URL, not send it literally" ); }); + +test("real generated combo-test command accepts and forwards its required request body", () => { + const src = readFileSync(REAL_COMBOS, "utf8"); + const testBlockMatch = src.match( + / {2}tag\.command\("post-api-combos-test"\)[\s\S]*?(?=\n {2}tag\.command\(|\n\})/ + ); + assert.ok(testBlockMatch, "combos.mjs must have a generated combo-test command block"); + const testBlock = testBlockMatch[0]; + + assert.match(testBlock, /\.requiredOption\("--body "/); + assert.match(testBlock, /const res = await apiFetch\(url, \{ method: "POST", body,/); +});