From f65a48077de3467e93177c6c199333dbfea9716a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:01:36 -0300 Subject: [PATCH] fix(vendor-portal): validate uploads against the server allowlist Any image/* or video/* type passed the client check, so GIF, WebP and WebM were only rejected after upload. Resolve the kind from the same allowlist the server uses: an allowlisted browser type, otherwise the extension. --- .../vendor-portal/lib/document-validation.ts | 33 ++++++++++++++----- .../app/v/vendor-portal-documents.test.tsx | 26 +++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index fef1f766..81444abc 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -14,15 +14,32 @@ const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files a type VendorUploadKind = "document" | "photo" | "video"; +// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the +// extension decides (mobile browsers often send an empty or octet-stream type). +const KIND_BY_MIME: Record = { + "application/pdf": "document", + "image/jpeg": "photo", + "image/jpg": "photo", + "image/png": "photo", + "image/heic": "photo", + "video/mp4": "video", + "video/quicktime": "video", +}; + +const KIND_BY_EXTENSION: Record = { + pdf: "document", + jpg: "photo", + jpeg: "photo", + png: "photo", + heic: "photo", + mp4: "video", + mov: "video", +}; + function resolveKind(file: File): VendorUploadKind | null { - const extension = file.name.toLowerCase().split(".").pop() ?? ""; - const mime = file.type.toLowerCase(); - if (mime.startsWith("video/") || extension === "mp4" || extension === "mov") return "video"; - if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "heic"].includes(extension)) { - return "photo"; - } - if (mime === "application/pdf" || extension === "pdf") return "document"; - return null; + const name = file.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null; } export async function validateVendorDocument(file: File): Promise { diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index 19507184..d97b1134 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -65,6 +65,32 @@ describe("VendorPortalDocuments", () => { expect(screen.getByText("Processing")).toBeInTheDocument(); }); + it.each([ + ["animation.gif", "image/gif"], + ["clip.webm", "video/webm"], + ])("rejects %s (%s), which the server does not accept", async (name, type) => { + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.change(screen.getByLabelText("Choose completion document"), { + target: { files: [new File(["x"], name, { type })] }, + }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.", + ); + expect(upload).not.toHaveBeenCalled(); + }); + describe("SH-116 media contract", () => { const MB = 1_000_000; const renderDocuments = () =>