diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 2b03832..ee9cb94 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -810,6 +810,38 @@ describe("PlatformApiClient AI providers", () => { expect(onAuthFailure).not.toHaveBeenCalled(); }); + it("surfaces safe validation details for delete denials", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + code: "validation_failed", + message: "validation failed", + details: [ + "running or installing server instances must be stopped before delete", + "config path /Users/operator/private/server.ini is unavailable" + ] + }), { status: 400, headers: { "Content-Type": "application/json" } }))); + + const client = new PlatformApiClient("/api/v1", () => "owner-session"); + await expect(client.deleteServerInstance("running-server", { password: "secret-password" })).rejects.toMatchObject({ + status: 400, + code: "validation_failed", + message: "运行中或安装中的服务器必须先停止再删除。;config path [host-path] is unavailable" + }); + }); + + it("surfaces password confirmation denials without exposing generic forbidden text", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + code: "forbidden", + message: "password confirmation failed" + }), { status: 403, headers: { "Content-Type": "application/json" } }))); + + const client = new PlatformApiClient("/api/v1", () => "owner-session"); + await expect(client.deleteServerInstance("server-1", { password: "wrong-password" })).rejects.toMatchObject({ + status: 403, + code: "forbidden", + message: "当前登录密码不正确,请重新输入。" + }); + }); + it("surfaces allow-listed plugin capability denials", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: "forbidden", diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 8043f62..d9efb74 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -752,7 +752,9 @@ async function responseError(response: Response): Promise { const apiError = await safeReadError(response); const message = response.status === 401 ? "会话已失效,请重新登录。" - : response.status === 403 + : apiError?.code === "validation_failed" + ? safeValidationMessage(apiError) + : response.status === 403 ? safeForbiddenMessage(apiError?.message) : apiError?.message ?? `request failed: ${response.status}`; const error = new PlatformApiError(response.status, apiError?.code ?? "request_failed", message); @@ -763,11 +765,37 @@ async function responseError(response: Response): Promise { return error; } +function safeValidationMessage(apiError?: ApiErrorResponse | null): string { + const details = apiError?.details + ?.map((detail) => safeValidationDetail(detail)) + .filter((detail): detail is string => Boolean(detail)); + if (details?.length) { + return details.slice(0, 3).join(";"); + } + const sanitized = safeDiagnosticText(apiError?.message, "")?.trim(); + return sanitized || "请求校验失败。"; +} + +function safeValidationDetail(detail: string): string | undefined { + const sanitized = safeDiagnosticText(detail, "")?.trim(); + switch (sanitized) { + case "password is required": + return "请输入当前登录密码。"; + case "running or installing server instances must be stopped before delete": + return "运行中或安装中的服务器必须先停止再删除。"; + default: + return sanitized || undefined; + } +} + function safeForbiddenMessage(apiMessage?: string): string { const sanitized = safeDiagnosticText(apiMessage, "")?.trim(); if (!sanitized || sanitized === "account is not allowed to access this resource") { return "没有权限访问该资源。"; } + if (sanitized === "password confirmation failed") { + return "当前登录密码不正确,请重新输入。"; + } const missingPermission = sanitized.match(/^plugin does not declare required permission:\s*([a-z0-9._-]+)$/i); if (missingPermission) { return `插件未声明所需权限:${missingPermission[1]}`;