diff --git a/electron/extensions/errorUtils.test.ts b/electron/extensions/errorUtils.test.ts new file mode 100644 index 000000000..5b834b3b9 --- /dev/null +++ b/electron/extensions/errorUtils.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { formatMarketplaceHttpError } from "./errorUtils"; + +describe("formatMarketplaceHttpError", () => { + it("hides upstream HTML when the marketplace is unavailable", () => { + const html = "
SSL handshake failed"; + + const message = formatMarketplaceHttpError({ + status: 525, + contentType: "text/html; charset=UTF-8", + body: html, + }); + + expect(message).toBe( + "Marketplace is temporarily unavailable (HTTP 525). Please try again later.", + ); + expect(message).not.toContain(html); + }); + + it("keeps a short JSON error for client-side request failures", () => { + expect( + formatMarketplaceHttpError({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "Invalid search query" }), + }), + ).toBe("Marketplace request failed (HTTP 400): Invalid search query"); + }); + + it("uses a JSON message when an error field is absent", () => { + expect( + formatMarketplaceHttpError({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ message: "Extension version already exists" }), + }), + ).toBe("Marketplace request failed (HTTP 409): Extension version already exists"); + }); + + it("prefers a string error when both JSON detail fields are present", () => { + expect( + formatMarketplaceHttpError({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "Primary detail", message: "Secondary detail" }), + }), + ).toBe("Marketplace request failed (HTTP 400): Primary detail"); + }); + + it("hides malformed JSON bodies", () => { + const body = '{"error":"internal route details"'; + const message = formatMarketplaceHttpError({ + status: 400, + contentType: "application/json", + body, + }); + + expect(message).toBe("Marketplace request failed (HTTP 400)."); + expect(message).not.toContain(body); + }); + + it("bounds long JSON details and marks truncation without splitting Unicode", () => { + const detail = `🚀${"x".repeat(200)}`; + const message = formatMarketplaceHttpError({ + status: 400, + contentType: "application/problem+json", + body: JSON.stringify({ error: detail }), + }); + + expect(message).toBe(`Marketplace request failed (HTTP 400): 🚀${"x".repeat(198)}…`); + expect(Array.from(message.split(": ")[1])).toHaveLength(200); + }); + + it("does not expose non-JSON response bodies", () => { + expect( + formatMarketplaceHttpError({ + status: 404, + contentType: "text/plain", + body: "internal route details", + }), + ).toBe("Marketplace request failed (HTTP 404)."); + }); +}); diff --git a/electron/extensions/errorUtils.ts b/electron/extensions/errorUtils.ts index ae5ccfdac..7d44675c3 100644 --- a/electron/extensions/errorUtils.ts +++ b/electron/extensions/errorUtils.ts @@ -1,3 +1,43 @@ export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); -} \ No newline at end of file +} + +const MAX_MARKETPLACE_ERROR_DETAIL_LENGTH = 200; + +export function formatMarketplaceHttpError({ + status, + contentType, + body, +}: { + status: number; + contentType: string | null; + body: string; +}): string { + if (status >= 500) { + return `Marketplace is temporarily unavailable (HTTP ${status}). Please try again later.`; + } + + let detail: string | null = null; + if (contentType?.toLowerCase().includes("json")) { + try { + const payload: unknown = JSON.parse(body); + if (payload && typeof payload === "object") { + const { error, message } = payload as { error?: unknown; message?: unknown }; + const value = typeof error === "string" ? error : message; + if (typeof value === "string" && value.trim()) { + const normalized = value.trim().replace(/\s+/g, " "); + const codePoints = Array.from(normalized); + detail = + codePoints.length > MAX_MARKETPLACE_ERROR_DETAIL_LENGTH + ? `${codePoints.slice(0, MAX_MARKETPLACE_ERROR_DETAIL_LENGTH - 1).join("")}…` + : normalized; + } + } + } catch { + // Malformed or non-API responses are intentionally not exposed to the renderer. + } + } + + const summary = `Marketplace request failed (HTTP ${status})`; + return detail ? `${summary}: ${detail}` : `${summary}.`; +} diff --git a/electron/extensions/extensionMarketplace.ts b/electron/extensions/extensionMarketplace.ts index e8bc66ddf..b7a38e46b 100644 --- a/electron/extensions/extensionMarketplace.ts +++ b/electron/extensions/extensionMarketplace.ts @@ -12,7 +12,7 @@ import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import { app } from "electron"; -import { getErrorMessage } from "./errorUtils"; +import { formatMarketplaceHttpError, getErrorMessage } from "./errorUtils"; import { getRegisteredExtensions, installExtensionFromPath } from "./extensionLoader"; import type { ExtensionReview, @@ -97,7 +97,13 @@ async function marketplaceFetch