165 lines
5.3 KiB
TypeScript
165 lines
5.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import {
|
||
CreateFlowUploadValidationError,
|
||
maxBytesForPurpose,
|
||
validateCreateFlowUploadBytes,
|
||
validateCreateFlowUploadFile,
|
||
} from "../../lib/create/createFlowUploadValidation";
|
||
import { uploadCreateFlowFile } from "../../lib/create/uploadToServer";
|
||
|
||
/** 1×1 PNG (valid IHDR). */
|
||
const PNG_1X1 = Uint8Array.from(
|
||
Buffer.from(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||
"base64",
|
||
),
|
||
);
|
||
|
||
/** SOF0 1×1 JPEG (header-decodable; not necessarily a complete scan). */
|
||
const JPEG_1X1_SOF = Uint8Array.from([
|
||
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
|
||
0x11, 0x00, 0xff, 0xd9,
|
||
]);
|
||
|
||
const GIF_1X1 = Uint8Array.from([
|
||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||
0x3b,
|
||
]);
|
||
|
||
/** VP8X 1×1 WebP. */
|
||
const WEBP_1X1 = Uint8Array.from([
|
||
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56,
|
||
0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||
0x00, 0x00, 0x00, 0x00,
|
||
]);
|
||
|
||
const PDF_STUB = new TextEncoder().encode("%PDF-1.4\n%%EOF\n");
|
||
|
||
const SVG_MARKUP = new TextEncoder().encode(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
|
||
);
|
||
|
||
const XML_SVG = new TextEncoder().encode(
|
||
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||
);
|
||
|
||
describe("validateCreateFlowUploadBytes", () => {
|
||
it("accepts a real PNG as a community avatar", () => {
|
||
const result = validateCreateFlowUploadBytes("communityAvatar", PNG_1X1);
|
||
expect(result).toEqual({
|
||
ok: true,
|
||
kind: "png",
|
||
mimeType: "image/png",
|
||
});
|
||
});
|
||
|
||
it("accepts JPEG, GIF, and WebP headers", () => {
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", JPEG_1X1_SOF).ok).toBe(
|
||
true,
|
||
);
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", GIF_1X1).ok).toBe(
|
||
true,
|
||
);
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", WEBP_1X1).ok).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("rejects an empty buffer", () => {
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", new Uint8Array())).toEqual(
|
||
{ ok: false, reason: "empty" },
|
||
);
|
||
});
|
||
|
||
it("rejects a file larger than the purpose cap", () => {
|
||
const oversized = new Uint8Array(maxBytesForPurpose("communityAvatar") + 1);
|
||
oversized.set(PNG_1X1.subarray(0, 8), 0);
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", oversized)).toEqual({
|
||
ok: false,
|
||
reason: "tooLarge",
|
||
});
|
||
expect(maxBytesForPurpose("customMethodAttachment")).toBeGreaterThan(
|
||
maxBytesForPurpose("communityAvatar"),
|
||
);
|
||
});
|
||
|
||
it("rejects text spoofed as PNG", () => {
|
||
expect(
|
||
validateCreateFlowUploadBytes(
|
||
"communityAvatar",
|
||
new TextEncoder().encode("hello world"),
|
||
),
|
||
).toEqual({ ok: false, reason: "invalidType" });
|
||
});
|
||
|
||
it("rejects SVG markup, including xml-prefixed SVG", () => {
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", SVG_MARKUP)).toEqual(
|
||
{ ok: false, reason: "svg" },
|
||
);
|
||
expect(validateCreateFlowUploadBytes("customMethodAttachment", XML_SVG)).toEqual(
|
||
{ ok: false, reason: "svg" },
|
||
);
|
||
});
|
||
|
||
it("rejects a PNG signature that cannot be decoded", () => {
|
||
const truncated = PNG_1X1.subarray(0, 8);
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", truncated)).toEqual({
|
||
ok: false,
|
||
reason: "undecodable",
|
||
});
|
||
});
|
||
|
||
it("rejects PDF for community avatars and allows it for attachments", () => {
|
||
expect(validateCreateFlowUploadBytes("communityAvatar", PDF_STUB)).toEqual({
|
||
ok: false,
|
||
reason: "invalidType",
|
||
});
|
||
expect(validateCreateFlowUploadBytes("customMethodAttachment", PDF_STUB)).toEqual(
|
||
{
|
||
ok: true,
|
||
kind: "pdf",
|
||
mimeType: "application/pdf",
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("validateCreateFlowUploadFile", () => {
|
||
it("rejects empty files before reading bytes", async () => {
|
||
const file = new File([], "empty.png", { type: "image/png" });
|
||
await expect(
|
||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||
).resolves.toEqual({ ok: false, reason: "empty" });
|
||
});
|
||
|
||
it("rejects SVG by name even when the MIME is spoofed", async () => {
|
||
const file = new File([PNG_1X1], "logo.svg", { type: "image/png" });
|
||
await expect(
|
||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||
).resolves.toEqual({ ok: false, reason: "svg" });
|
||
});
|
||
|
||
it("rejects a 17MB file by size without treating it as a type error", async () => {
|
||
const file = new File([new Uint8Array([0x89, 0x50])], "huge.png", {
|
||
type: "image/png",
|
||
});
|
||
Object.defineProperty(file, "size", { value: 17 * 1024 * 1024 });
|
||
await expect(
|
||
validateCreateFlowUploadFile(file, "communityAvatar"),
|
||
).resolves.toEqual({ ok: false, reason: "tooLarge" });
|
||
});
|
||
});
|
||
|
||
describe("uploadCreateFlowFile", () => {
|
||
it("does not POST when client validation fails", async () => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
const file = new File(["not an image"], "photo.png", { type: "image/png" });
|
||
await expect(
|
||
uploadCreateFlowFile(file, "communityAvatar"),
|
||
).rejects.toBeInstanceOf(CreateFlowUploadValidationError);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
});
|