From 637bb8c3648a2425f63e63de9c7cc16072969169 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 27 Apr 2026 20:10:36 +0000 Subject: [PATCH] fix(api): throw ValidationError for user-input failures in api command (CLI-1GC) Several validation paths in 'sentry api' threw plain 'new Error()' from inside the command's func() body. These bypass the CliError handling chain in app.ts, so users saw 'Unexpected error:' with a full stack trace and the error was captured to Sentry as a CLI bug rather than silenced as an expected user error. Convert seven sites to ValidationError with structured 'field' metadata: - buildBodyFromInput: file not found (CLI-1GC trigger) - parseHeaders: invalid header format - parseFieldKey/validatePathSegments: invalid field key format - validateTypeCompatibility: field type conflicts parseMethod (line 74) keeps a plain Error since it runs in a Stricli parse callback where Stricli catches and formats it. --- src/commands/api.ts | 29 +++++++++++++++++++---------- test/commands/api.test.ts | 4 ++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/commands/api.ts b/src/commands/api.ts index a238cf78e..a80334263 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -164,7 +164,7 @@ const BRACKET_CONTENTS_REGEX = /\[([^[\]]*)\]/g; export function parseFieldKey(key: string): string[] { const match = key.match(FIELD_KEY_REGEX); if (!match?.[1]) { - throw new Error(`Invalid field key format: ${key}`); + throw new ValidationError(`Invalid field key format: ${key}`, "field"); } const baseKey = match[1]; @@ -190,14 +190,18 @@ function validatePathSegments(path: string[]): void { // Check for prototype pollution if (DANGEROUS_KEYS.has(segment)) { - throw new Error(`Invalid field key: "${segment}" is not allowed`); + throw new ValidationError( + `Invalid field key: "${segment}" is not allowed`, + "field" + ); } // Empty brackets ("") are only valid at the end of the path (array push syntax) // Reject patterns like a[][b] which would silently lose data if (segment === "" && i < path.length - 1) { - throw new Error( - "Invalid field key: empty brackets [] can only appear at the end of a key" + throw new ValidationError( + "Invalid field key: empty brackets [] can only appear at the end of a key", + "field" ); } } @@ -249,14 +253,16 @@ function validateTypeCompatibility( const pathStr = formatPathForError(path, index); if (expectsArray && !Array.isArray(existing)) { - throw new Error( - `expected array type under "${pathStr}", got ${getTypeName(existing)}` + throw new ValidationError( + `expected array type under "${pathStr}", got ${getTypeName(existing)}`, + "field" ); } if (!(expectsArray || isTraversableObject(existing))) { - throw new Error( - `expected map type under "${pathStr}", got ${getTypeName(existing)}` + throw new ValidationError( + `expected map type under "${pathStr}", got ${getTypeName(existing)}`, + "field" ); } } @@ -628,7 +634,10 @@ export function parseHeaders(headers: string[]): Record { for (const header of headers) { const colonIndex = header.indexOf(":"); if (colonIndex === -1) { - throw new Error(`Invalid header format: ${header}. Expected Key: Value`); + throw new ValidationError( + `Invalid header format: ${header}. Expected Key: Value`, + "header" + ); } const key = header.substring(0, colonIndex).trim(); @@ -821,7 +830,7 @@ export async function buildBodyFromInput( } else { const file = Bun.file(inputPath); if (!(await file.exists())) { - throw new Error(`File not found: ${inputPath}`); + throw new ValidationError(`File not found: ${inputPath}`, "input"); } content = await file.text(); } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 34e7c319c..9b119ed33 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -1039,12 +1039,12 @@ describe("buildBodyFromInput", () => { } }); - test("throws for non-existent file", async () => { + test("throws ValidationError for non-existent file", async () => { const mockStdin = createMockStdin(""); await expect( buildBodyFromInput("/nonexistent/path/file.json", mockStdin) - ).rejects.toThrow(/File not found/); + ).rejects.toBeInstanceOf(ValidationError); }); });