Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docker/web/frontend/src/components/ScanFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,12 @@ export function FormMessages({ state }: { state: ScanFormState }) {
</p>
)}
{uploadError && (
<p className="text-sm text-destructive" role="alert">
{t("source.uploadFailed", { msg: uploadError })}
</p>
<div role="alert">
<p className="text-sm text-destructive">{t(uploadError.key)}</p>
{uploadError.detail && (
<p className="text-xs text-muted-foreground">{uploadError.detail}</p>
)}
</div>
)}
</>
);
Expand Down
51 changes: 49 additions & 2 deletions docker/web/frontend/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
absoluteFileUrl,
ApiError,
deleteScan,
describeUploadError,
downloadAllUrl,
fileUrl,
getCapabilities,
Expand Down Expand Up @@ -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<typeof vi.fn>;

Expand Down Expand Up @@ -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 () => {
Expand Down
40 changes: 38 additions & 2 deletions docker/web/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,42 @@ export async function getCapabilities(): Promise<Capabilities> {
}
}

/** 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,
Expand All @@ -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 };
}
Expand All @@ -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 };
}
Expand Down
12 changes: 7 additions & 5 deletions docker/web/frontend/src/lib/useScanForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -142,7 +144,7 @@ export function useScanForm({
);
const [scanossToken, setScanossToken] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<UploadErrorInfo | null>(null);
const [uploading, setUploading] = useState(false);

/** Typing into a field resolves its inline error immediately. */
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion docker/web/frontend/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion docker/web/frontend/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "고지문",
Expand Down
6 changes: 4 additions & 2 deletions docker/web/frontend/tests/ui/upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading