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.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 21:01:36 -03:00
parent 44e96cf2a3
commit f65a48077d
2 changed files with 51 additions and 8 deletions

View file

@ -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<string, VendorUploadKind> = {
"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<string, VendorUploadKind> = {
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<string | undefined> {

View file

@ -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(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn()}
/>,
{ 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 = () =>