diff --git a/docker/web/frontend/src/components/ScanFormFields.tsx b/docker/web/frontend/src/components/ScanFormFields.tsx index f95db12..0165638 100644 --- a/docker/web/frontend/src/components/ScanFormFields.tsx +++ b/docker/web/frontend/src/components/ScanFormFields.tsx @@ -478,9 +478,12 @@ export function FormMessages({ state }: { state: ScanFormState }) {

)} {uploadError && ( -

- {t("source.uploadFailed", { msg: uploadError })} -

+
+

{t(uploadError.key)}

+ {uploadError.detail && ( +

{uploadError.detail}

+ )} +
)} ); diff --git a/docker/web/frontend/src/lib/api.test.ts b/docker/web/frontend/src/lib/api.test.ts index 7dfc364..7528a68 100644 --- a/docker/web/frontend/src/lib/api.test.ts +++ b/docker/web/frontend/src/lib/api.test.ts @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { absoluteFileUrl, + ApiError, deleteScan, + describeUploadError, downloadAllUrl, fileUrl, getCapabilities, @@ -48,6 +50,48 @@ describe("URL builders", () => { // --------------------------------------------------------------------------- // fetch-backed network functions // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Upload-error mapping (pure): raw error text never becomes the headline +// --------------------------------------------------------------------------- +describe("describeUploadError", () => { + it("maps 413 to the too-large message with no raw detail", () => { + expect(describeUploadError(new ApiError("file too large for zip", 413))).toEqual({ + key: "source.uploadErrorTooLarge", + }); + }); + + it("maps 5xx to the server message, keeping the detail as fine print", () => { + expect(describeUploadError(new ApiError("upload failed (500)", 500))).toEqual({ + key: "source.uploadErrorServer", + detail: "upload failed (500)", + }); + }); + + it("maps other 4xx to the rejected message with the server detail", () => { + expect(describeUploadError(new ApiError("unsupported kind", 400))).toEqual({ + key: "source.uploadErrorRejected", + detail: "unsupported kind", + }); + }); + + it("maps a fetch network failure (TypeError) to the unreachable message", () => { + expect(describeUploadError(new TypeError("Failed to fetch"))).toEqual({ + key: "source.uploadErrorNetwork", + }); + }); + + it("falls back to the server message for unknown errors", () => { + expect(describeUploadError(new Error("boom"))).toEqual({ + key: "source.uploadErrorServer", + detail: "boom", + }); + expect(describeUploadError("weird")).toEqual({ + key: "source.uploadErrorServer", + detail: undefined, + }); + }); +}); + describe("network functions", () => { let fetchMock: ReturnType; @@ -76,9 +120,12 @@ describe("network functions", () => { expect((init.body as FormData).get("kind")).toBe("zip"); }); - it("uploadFile throws the server error message on failure", async () => { + it("uploadFile throws an ApiError carrying the status and server message", async () => { fetchMock.mockResolvedValue(fail(413, { error: "too big" })); - await expect(uploadFile(new File([""], "a"), "zip")).rejects.toThrow("too big"); + const err = await uploadFile(new File([""], "a"), "zip").catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(413); + expect(err.message).toBe("too big"); }); it("stashGitCred POSTs a JSON token and returns the credId", async () => { diff --git a/docker/web/frontend/src/lib/api.ts b/docker/web/frontend/src/lib/api.ts index 8add497..ec036a0 100644 --- a/docker/web/frontend/src/lib/api.ts +++ b/docker/web/frontend/src/lib/api.ts @@ -411,6 +411,42 @@ export async function getCapabilities(): Promise { } } +/** Error from a pre-scan POST (/upload, /git-cred): carries the HTTP status so + * the form can pick a human message; `message` keeps the server's raw detail. */ +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +/** What the scan form shows when a pre-scan POST fails: `key` is an i18n + * message key (the headline the user reads), `detail` the raw server/browser + * text kept as secondary fine print for bug reports. */ +export interface UploadErrorInfo { + key: string; + detail?: string; +} + +/** Map an upload/stash failure to a user-facing message key. Raw error text + * (HTTP statuses, "Failed to fetch") never becomes the headline. */ +export function describeUploadError(e: unknown): UploadErrorInfo { + if (e instanceof ApiError) { + if (e.status === 413) return { key: "source.uploadErrorTooLarge" }; + if (e.status >= 500) + return { key: "source.uploadErrorServer", detail: e.message }; + return { key: "source.uploadErrorRejected", detail: e.message }; + } + // fetch() rejects with a TypeError when the server is unreachable. + if (e instanceof TypeError) return { key: "source.uploadErrorNetwork" }; + return { + key: "source.uploadErrorServer", + detail: e instanceof Error ? e.message : undefined, + }; +} + /** Upload a file (zip/sbom/firmware) and get back a server-side token. */ export async function uploadFile( file: File, @@ -431,7 +467,7 @@ export async function uploadFile( } catch { /* keep default */ } - throw new Error(msg); + throw new ApiError(msg, res.status); } return (await res.json()) as { token: string; filename: string }; } @@ -451,7 +487,7 @@ export async function stashGitCred(token: string): Promise<{ credId: string }> { } catch { /* keep default */ } - throw new Error(msg); + throw new ApiError(msg, res.status); } return (await res.json()) as { credId: string }; } diff --git a/docker/web/frontend/src/lib/useScanForm.ts b/docker/web/frontend/src/lib/useScanForm.ts index 017b5e0..76a345c 100644 --- a/docker/web/frontend/src/lib/useScanForm.ts +++ b/docker/web/frontend/src/lib/useScanForm.ts @@ -7,12 +7,14 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; import { + describeUploadError, stashGitCred, uploadFile, type Capabilities, type ScanConfig, type ScanParams, type SourceType, + type UploadErrorInfo, type UploadKind, } from "@/lib/api"; import { parseSbomIdentity, suggestIdentity } from "@/lib/scanDefaults"; @@ -142,7 +144,7 @@ export function useScanForm({ ); const [scanossToken, setScanossToken] = useState(""); const [errors, setErrors] = useState({}); - const [uploadError, setUploadError] = useState(null); + const [uploadError, setUploadError] = useState(null); const [uploading, setUploading] = useState(false); /** Typing into a field resolves its inline error immediately. */ @@ -306,7 +308,7 @@ export function useScanForm({ setUploading(true); token = (await uploadFile(file, uploadKind)).token; } catch (e) { - setUploadError((e as Error).message); + setUploadError(describeUploadError(e)); setUploading(false); return; } @@ -318,7 +320,7 @@ export function useScanForm({ setUploading(true); cred = (await stashGitCred(gitToken.trim())).credId; } catch (e) { - setUploadError((e as Error).message); + setUploadError(describeUploadError(e)); setUploading(false); return; } @@ -332,7 +334,7 @@ export function useScanForm({ setUploading(true); scanossCred = (await stashGitCred(scanossToken.trim())).credId; } catch (e) { - setUploadError((e as Error).message); + setUploadError(describeUploadError(e)); setUploading(false); return; } @@ -346,7 +348,7 @@ export function useScanForm({ setUploading(true); uploadCred = (await stashGitCred(uploadToken.trim())).credId; } catch (e) { - setUploadError((e as Error).message); + setUploadError(describeUploadError(e)); setUploading(false); return; } diff --git a/docker/web/frontend/src/locales/en/common.json b/docker/web/frontend/src/locales/en/common.json index edc1e96..8851ece 100644 --- a/docker/web/frontend/src/locales/en/common.json +++ b/docker/web/frontend/src/locales/en/common.json @@ -51,7 +51,10 @@ "aiModelHint": "Enter a HuggingFace model ID as 'org/model' (e.g. Qwen/Qwen2.5-0.5B) — not a collection name or a full URL. Fetches model-card metadata over the network to build a CycloneDX ML-BOM.", "aiModelUnavailable": "AI-model SBOMs need Docker running (the AIBOM image runs as a sibling container).", "uploading": "Uploading…", - "uploadFailed": "Upload failed: {{msg}}" + "uploadErrorTooLarge": "The file is too large for the server to accept. Try a smaller archive, or scan the unpacked folder instead.", + "uploadErrorNetwork": "Could not reach the scan server. Check that it is still running, then try again.", + "uploadErrorServer": "The server could not process the request. Try again; if it keeps failing, restart the scanner.", + "uploadErrorRejected": "The server did not accept the upload. Check the file and try again." }, "options": { "notice": "Notice", diff --git a/docker/web/frontend/src/locales/ko/common.json b/docker/web/frontend/src/locales/ko/common.json index e2bc005..6c8b8b3 100644 --- a/docker/web/frontend/src/locales/ko/common.json +++ b/docker/web/frontend/src/locales/ko/common.json @@ -51,7 +51,10 @@ "aiModelHint": "HuggingFace 모델 ID를 '조직/모델' 형식으로 입력합니다(예: Qwen/Qwen2.5-0.5B). 컬렉션 이름이나 전체 URL이 아닙니다. 모델 카드 메타데이터를 네트워크로 가져와 CycloneDX ML-BOM을 생성합니다.", "aiModelUnavailable": "AI 모델 SBOM은 Docker가 실행 중이어야 사용할 수 있습니다(AIBOM 이미지를 sibling 컨테이너로 실행합니다).", "uploading": "업로드 중…", - "uploadFailed": "업로드 실패: {{msg}}" + "uploadErrorTooLarge": "파일이 커서 서버가 받을 수 없습니다. 더 작은 압축본으로 다시 시도하거나, 압축을 푼 폴더를 스캔해 주세요.", + "uploadErrorNetwork": "스캔 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인하고 다시 시도해 주세요.", + "uploadErrorServer": "서버가 요청을 처리하지 못했습니다. 다시 시도하고, 계속 실패하면 스캐너를 재시작해 주세요.", + "uploadErrorRejected": "서버가 업로드를 받지 않았습니다. 파일을 확인하고 다시 시도해 주세요." }, "options": { "notice": "고지문", diff --git a/docker/web/frontend/tests/ui/upload.spec.ts b/docker/web/frontend/tests/ui/upload.spec.ts index 98b5151..5da65a4 100644 --- a/docker/web/frontend/tests/ui/upload.spec.ts +++ b/docker/web/frontend/tests/ui/upload.spec.ts @@ -63,6 +63,8 @@ test("a failed upload surfaces an error instead of running the scan", async ({ p await page.goto("/#/new"); await selectZipAndAttach(page); await page.getByRole("button", { name: /Run scan/i }).click(); - // The upload error is shown to the user (uploadFailed message), scan not started. - await expect(page.getByText(/file too large/i)).toBeVisible(); + // The user sees the humanized too-large message, not the server's raw + // error text, and the scan does not start. + await expect(page.getByText(/file is too large for the server/i)).toBeVisible(); + await expect(page.getByText("file too large for zip")).toHaveCount(0); });