shoc-frontend-new/src/test/app/v/vendor-portal-documents.test.tsx
Alexandre Brandizzi f65a48077d 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.
2026-09-24 21:01:36 -03:00

183 lines
5.7 KiB
TypeScript

import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const quarantinedDocument: VendorPortalDocument = {
id: 12,
originalFileName: "completion.pdf",
contentType: "application/pdf",
sizeBytes: 1024,
scanStatus: "Pending",
reviewStatus: "Processing",
version: 1,
canDownload: false,
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("VendorPortalDocuments", () => {
it("rejects unsupported files before starting an upload", async () => {
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(["text"], "notes.txt", { type: "text/plain" })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
it("keeps pending documents quarantined", () => {
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[quarantinedDocument]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "Quarantined" })).toBeDisabled();
expect(screen.getByText("Pending")).toBeInTheDocument();
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 = () =>
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const choose = (file: File) =>
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [file] },
});
it("uploads a ~60 MB phone MOV whose browser type is empty", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderDocuments();
const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File;
choose(video);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0].file).toBe(video);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each([
[
{ name: "clip.mp4", type: "video/mp4", size: 101 * MB },
"Videos must be 100 MB or smaller.",
],
[
{ name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB },
"Photos must be 10 MB or smaller.",
],
])("rejects %o with a generic message", async (file, message) => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderDocuments();
choose(file as File);
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(upload).not.toHaveBeenCalled();
});
});
it("reports upload progress and refreshes the dispatch after success", async () => {
const onChanged = vi.fn().mockResolvedValue(undefined);
let finishUpload: (() => void) | undefined;
vi.spyOn(vendorPortalApi, "uploadDocument").mockImplementation(async ({ onProgress }) => {
onProgress(65);
await new Promise<void>((resolve) => {
finishUpload = resolve;
});
return quarantinedDocument;
});
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={onChanged}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: {
files: [
new File(["%PDF-1.4"], "completion.pdf", {
type: "application/pdf",
}),
],
},
});
expect(await screen.findByText("Uploading… 65%")).toBeInTheDocument();
finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
});
});