import { beforeEach, describe, expect, test, vi } from "vitest"; import { StorageErrorCode } from "@formbricks/storage"; import { TResponseData } from "@formbricks/types/responses"; import { ZAllowedFileExtension } from "@formbricks/types/storage"; import { TSurveyQuestion } from "@formbricks/types/surveys/types"; import { isAllowedFileExtension, isValidImageFile, resolveStorageUrl, resolveStorageUrlAuto, resolveStorageUrlsInObject, sanitizeFileName, validateFileUploads, validateSingleFile, } from "@/modules/storage/utils"; // Mock the getOriginalFileNameFromUrl function const mockGetOriginalFileNameFromUrl = vi.hoisted(() => vi.fn()); vi.mock("@/modules/storage/utils", async () => { const actual = await vi.importActual("@/modules/storage/utils"); return { ...actual, getOriginalFileNameFromUrl: mockGetOriginalFileNameFromUrl, }; }); describe("storage utils", () => { beforeEach(() => { vi.resetAllMocks(); // Default: derive filename from URL path for positive-path tests mockGetOriginalFileNameFromUrl.mockImplementation((url: string) => { try { return new URL(url).pathname.split("/").filter(Boolean).pop(); } catch { return undefined; } }); }); describe("sanitizeFileName", () => { test("returns empty string for empty input", () => { expect(sanitizeFileName("")).toBe(""); }); test("keeps a normal filename unchanged", () => { expect(sanitizeFileName("photo.jpg")).toBe("photo.jpg"); }); test("replaces slashes and backslashes with dashes", () => { expect(sanitizeFileName("a/b\\c.txt")).toBe("a-b-c.txt"); }); test("removes reserved and control characters including #", () => { expect(sanitizeFileName("we<>:\"|?*`'#ird.pdf")).toBe("weird.pdf"); expect(sanitizeFileName("test#file.png")).toBe("testfile.png"); }); test("collapses whitespace and trims", () => { expect(sanitizeFileName(" my file name .jpg ")).toBe("my file name.jpg"); }); test("keeps only last extension when multiple dots present", () => { expect(sanitizeFileName("my.backup.file.pdf")).toBe("my.backup.file.pdf"); }); test("returns empty string for base of only hyphens or dots", () => { expect(sanitizeFileName("----.png")).toBe(""); expect(sanitizeFileName("....png")).toBe(""); }); test("sanitizes extension to alphanumeric only", () => { expect(sanitizeFileName("file.pn#g")).toBe("file.png"); }); test("truncates overly long base name", () => { const longBase = "a".repeat(300); const result = sanitizeFileName(`${longBase}.txt`); // base should be cut to 200 chars expect(result).toBe(`${"a".repeat(200)}.txt`); }); }); describe("getErrorResponseFromStorageError", () => { test("returns appropriate responses for each storage error code", async () => { // Spy on real module; keep behavior isolated to this test const responseMod = await import("@/app/lib/api/response"); const spyNotFound = vi .spyOn(responseMod.responses, "notFoundResponse") .mockImplementation( (_entity: string, _id?: string | null, _public?: boolean) => new Response(null, { status: 404 }) ); const spyBadReq = vi .spyOn(responseMod.responses, "badRequestResponse") .mockImplementation( (_msg: string, _details?: unknown, _public?: boolean) => new Response(null, { status: 400 }) ); const spyISE = vi .spyOn(responseMod.responses, "internalServerErrorResponse") .mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 })); const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils"); // FileNotFoundError uses notFoundResponse with details.fileName or null const r404 = getErrorResponseFromStorageError( { code: StorageErrorCode.FileNotFoundError }, { fileName: "file.png", } ); expect(r404.status).toBe(404); // InvalidInput -> 400 const r400 = getErrorResponseFromStorageError({ code: StorageErrorCode.InvalidInput }, { reason: "bad", } as any); expect(r400.status).toBe(400); // S3 related and Unknown -> 500 const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError }); expect(r500a.status).toBe(500); const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError }); expect(r500b.status).toBe(500); const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown }); expect(r500c.status).toBe(500); // Default branch (unknown string) -> 500 const r500d = getErrorResponseFromStorageError({ code: "something_else" as any }); expect(r500d.status).toBe(500); spyNotFound.mockRestore(); spyBadReq.mockRestore(); spyISE.mockRestore(); }); }); describe("getOriginalFileNameFromUrl (actual)", () => { test("extracts original name from full URL with fid and extension", async () => { const { getOriginalFileNameFromUrl } = await vi.importActual("@/modules/storage/utils"); const url = "https://cdn.example.com/storage/env/public/photo--fid--12345.png?x=1#hash"; expect(getOriginalFileNameFromUrl(url)).toBe("photo.png"); }); test("handles /storage/ relative path and missing fid", async () => { const { getOriginalFileNameFromUrl } = await vi.importActual("@/modules/storage/utils"); const path = "/storage/env/public/Document%20Name.pdf"; // Function extracts filename, not full path expect(getOriginalFileNameFromUrl(path)).toBe("Document Name.pdf"); }); test("returns empty string on invalid URL input", async () => { const { getOriginalFileNameFromUrl } = await vi.importActual("@/modules/storage/utils"); expect(getOriginalFileNameFromUrl("ht!tp://%$^&")).toBe(""); }); }); describe("isAllowedFileExtension", () => { test("should return false for a file with no extension", () => { expect(isAllowedFileExtension("filename")).toBe(false); }); test("should return false for a file with extension not in allowed list", () => { expect(isAllowedFileExtension("malicious.exe")).toBe(false); expect(isAllowedFileExtension("script.php")).toBe(false); expect(isAllowedFileExtension("config.js")).toBe(false); expect(isAllowedFileExtension("page.html")).toBe(false); }); test("should return true for an allowed file extension", () => { Object.values(ZAllowedFileExtension.enum).forEach((ext) => { expect(isAllowedFileExtension(`file.${ext}`)).toBe(true); }); }); test("should handle case insensitivity correctly", () => { expect(isAllowedFileExtension("image.PNG")).toBe(true); expect(isAllowedFileExtension("document.PDF")).toBe(true); }); test("should handle filenames with multiple dots", () => { expect(isAllowedFileExtension("example.backup.pdf")).toBe(true); expect(isAllowedFileExtension("document.old.exe")).toBe(false); }); }); describe("validateSingleFile", () => { test("should return true for allowed file extension", () => { mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg"); expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true); }); test("should return false for disallowed file extension", () => { mockGetOriginalFileNameFromUrl.mockReturnValueOnce("malicious.exe"); expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false); }); test("should return true when no allowed extensions are specified", () => { mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg"); expect(validateSingleFile("https://example.com/image.jpg")).toBe(true); }); test("should return false when file has no extension", () => { mockGetOriginalFileNameFromUrl.mockReturnValueOnce("filewithoutextension"); expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false); }); }); describe("validateFileUploads", () => { test("should return true for valid file uploads in response data", () => { const responseData = { question1: ["https://example.com/storage/file1.jpg", "https://example.com/storage/file2.pdf"], }; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg", "pdf"], } as TSurveyQuestion, ]; expect(validateFileUploads(responseData, questions)).toBe(true); }); test("should return false when file url is not a string", () => { const responseData = { question1: [123, "https://example.com/storage/file.jpg"], } as TResponseData; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg"], } as TSurveyQuestion, ]; expect(validateFileUploads(responseData, questions)).toBe(false); }); test("should return false when file urls are not in an array", () => { const responseData = { question1: "https://example.com/storage/file.jpg", }; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg"], } as TSurveyQuestion, ]; expect(validateFileUploads(responseData, questions)).toBe(false); }); test("should return false when file extension is not allowed", () => { const responseData = { question1: ["https://example.com/storage/file.exe"], }; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg", "pdf"], } as TSurveyQuestion, ]; expect(validateFileUploads(responseData, questions)).toBe(false); }); test("should return false when file name cannot be extracted", () => { // Mock implementation to return null for this specific URL mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined); const responseData = { question1: ["https://example.com/invalid-url"], }; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg"], } as TSurveyQuestion, ]; expect(validateFileUploads(responseData, questions)).toBe(false); }); test("should return false when file has no extension", () => { mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "file-without-extension"); const responseData = { question1: ["https://example.com/storage/file-without-extension"], }; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg"], } as TSurveyQuestion, ]; expect(validateFileUploads(responseData, questions)).toBe(false); }); test("should ignore non-fileUpload questions", () => { const responseData = { question1: ["https://example.com/storage/file.jpg"], question2: "Some text answer", }; const questions = [ { id: "question1", type: "fileUpload" as const, allowedFileExtensions: ["jpg"], }, { id: "question2", type: "text" as const, }, ] as TSurveyQuestion[]; expect(validateFileUploads(responseData, questions)).toBe(true); }); test("should return true when no questions are provided", () => { const responseData = { question1: ["https://example.com/storage/file.jpg"], }; expect(validateFileUploads(responseData)).toBe(true); }); }); describe("isValidImageFile", () => { test("should return true for valid image file extensions", () => { expect(isValidImageFile("https://example.com/image.jpg")).toBe(true); expect(isValidImageFile("https://example.com/image.jpeg")).toBe(true); expect(isValidImageFile("https://example.com/image.png")).toBe(true); expect(isValidImageFile("https://example.com/image.webp")).toBe(true); expect(isValidImageFile("https://example.com/image.heic")).toBe(true); }); test("should return false for non-image file extensions", () => { expect(isValidImageFile("https://example.com/document.pdf")).toBe(false); expect(isValidImageFile("https://example.com/document.docx")).toBe(false); expect(isValidImageFile("https://example.com/document.txt")).toBe(false); }); test("should return false when file name cannot be extracted", () => { mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined); expect(isValidImageFile("https://example.com/invalid-url")).toBe(false); }); test("should return false when file has no extension", () => { mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image-without-extension"); expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false); }); test("should return false when file name ends with a dot", () => { mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image."); expect(isValidImageFile("https://example.com/image.")).toBe(false); }); test("should handle case insensitivity correctly", () => { mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image.JPG"); expect(isValidImageFile("https://example.com/image.JPG")).toBe(true); }); }); describe("resolveStorageUrl", () => { test("should return empty string for null or undefined input", () => { expect(resolveStorageUrl(null)).toBe(""); expect(resolveStorageUrl(undefined)).toBe(""); expect(resolveStorageUrl("")).toBe(""); }); test("should return absolute URL unchanged", () => { const httpsUrl = "https://example.com/storage/env-123/public/image.jpg"; const httpUrl = "http://example.com/storage/env-123/public/image.jpg"; expect(resolveStorageUrl(httpsUrl)).toBe(httpsUrl); expect(resolveStorageUrl(httpUrl)).toBe(httpUrl); }); test("should resolve relative /storage/ path to absolute URL", async () => { const { resolveStorageUrl: actualResolveStorageUrl } = await vi.importActual("@/modules/storage/utils"); const relativePath = "/storage/env-123/public/image.jpg"; const result = actualResolveStorageUrl(relativePath); expect(result).toContain("/storage/env-123/public/image.jpg"); expect(result.startsWith("http")).toBe(true); }); test("should return non-storage paths unchanged", () => { expect(resolveStorageUrl("/some/other/path")).toBe("/some/other/path"); expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg"); }); }); describe("resolveStorageUrlAuto", () => { test("should return non-storage strings unchanged", () => { expect(resolveStorageUrlAuto("hello world")).toBe("hello world"); expect(resolveStorageUrlAuto("/some/other/path")).toBe("/some/other/path"); expect(resolveStorageUrlAuto("https://example.com/image.jpg")).toBe("https://example.com/image.jpg"); }); test("should NOT transform free-text values that merely start with /storage/", () => { expect(resolveStorageUrlAuto("/storage/help")).toBe("/storage/help"); expect(resolveStorageUrlAuto("/storage/")).toBe("/storage/"); expect(resolveStorageUrlAuto("/storage/some-text")).toBe("/storage/some-text"); expect(resolveStorageUrlAuto("/storage/foo/bar")).toBe("/storage/foo/bar"); }); test("should resolve public storage URL", async () => { const { resolveStorageUrlAuto: actual } = await vi.importActual("@/modules/storage/utils"); const result = actual("/storage/env-123/public/image.jpg"); expect(result).toContain("/storage/env-123/public/image.jpg"); expect(result.startsWith("http")).toBe(true); }); test("should detect private access type from URL path", () => { const privateUrl = "/storage/env-123/private/file.pdf"; const publicUrl = "/storage/env-123/public/image.jpg"; expect(privateUrl.includes("/private/")).toBe(true); expect(publicUrl.includes("/private/")).toBe(false); }); }); describe("resolveStorageUrlsInObject", () => { test("should return null and undefined as-is", () => { expect(resolveStorageUrlsInObject(null)).toBeNull(); expect(resolveStorageUrlsInObject(undefined)).toBeUndefined(); }); test("should return primitive values unchanged", () => { expect(resolveStorageUrlsInObject(42)).toBe(42); expect(resolveStorageUrlsInObject(true)).toBe(true); expect(resolveStorageUrlsInObject("hello")).toBe("hello"); }); test("should NOT transform free-text that merely starts with /storage/", () => { expect(resolveStorageUrlsInObject("/storage/help")).toBe("/storage/help"); expect(resolveStorageUrlsInObject("/storage/")).toBe("/storage/"); const input = { questionId1: "/storage/", questionId2: "/storage/help", questionId3: "/storage/some-text", questionId4: "/storage/foo/bar", realUrl: "/storage/env-123/public/image.jpg", }; const result = resolveStorageUrlsInObject(input); expect(result.questionId1).toBe("/storage/"); expect(result.questionId2).toBe("/storage/help"); expect(result.questionId3).toBe("/storage/some-text"); expect(result.questionId4).toBe("/storage/foo/bar"); // realUrl still gets resolved because it matches the actual format expect(result.realUrl).not.toBe("/storage/env-123/public/image.jpg"); }); test("should preserve Date instances", () => { const date = new Date("2026-01-01"); expect(resolveStorageUrlsInObject(date)).toBe(date); }); test("should resolve storage URL strings", async () => { const { resolveStorageUrlsInObject: actual } = await vi.importActual("@/modules/storage/utils"); const result = actual("/storage/env-123/public/image.jpg"); expect(typeof result).toBe("string"); expect(result).toContain("/storage/env-123/public/image.jpg"); expect((result as string).startsWith("http")).toBe(true); }); test("should resolve URLs in arrays", async () => { const { resolveStorageUrlsInObject: actual } = await vi.importActual("@/modules/storage/utils"); const input = ["/storage/env-123/public/a.jpg", "plain text"]; const result = actual(input); expect(result[0]).toContain("/storage/env-123/public/a.jpg"); expect(result[0].startsWith("http")).toBe(true); expect(result[1]).toBe("plain text"); }); test("should resolve URLs in nested objects", async () => { const { resolveStorageUrlsInObject: actual } = await vi.importActual("@/modules/storage/utils"); const input = { name: "Test Survey", welcomeCard: { fileUrl: "/storage/env-123/public/welcome.png", headline: "Hello", }, elements: [ { imageUrl: "/storage/env-123/public/q1.jpg", choices: [ { id: "c1", imageUrl: "/storage/env-123/public/choice1.jpg" }, { id: "c2", imageUrl: "https://external.com/image.jpg" }, ], }, ], count: 5, createdAt: new Date("2026-01-01"), }; const result = actual(input); expect(result.welcomeCard.fileUrl.startsWith("http")).toBe(true); expect(result.welcomeCard.headline).toBe("Hello"); expect(result.elements[0].imageUrl.startsWith("http")).toBe(true); expect(result.elements[0].choices[0].imageUrl.startsWith("http")).toBe(true); expect(result.elements[0].choices[1].imageUrl).toBe("https://external.com/image.jpg"); expect(result.count).toBe(5); expect(result.createdAt).toEqual(new Date("2026-01-01")); expect(result.name).toBe("Test Survey"); }); test("should resolve URLs in deeply nested objects", async () => { const { resolveStorageUrlsInObject: actual } = await vi.importActual("@/modules/storage/utils"); const input = { level1: { level2: { level3: { level4: { level5: { imageUrl: "/storage/env-123/public/deep.png", items: [ { nested: { url: "/storage/env-123/public/nested.jpg", label: "keep me", }, }, "plain string", 42, null, ], }, }, sibling: "/storage/env-123/public/sibling.png", }, }, untouched: { a: { b: { c: "no change" } } }, }, }; const result = actual(input); expect(result.level1.level2.level3.level4.level5.imageUrl).toContain( "/storage/env-123/public/deep.png" ); expect(result.level1.level2.level3.level4.level5.imageUrl.startsWith("http")).toBe(true); // @ts-expect-error - items is an array of unknown types expect(result.level1.level2.level3.level4.level5.items[0].nested.url).toContain( "/storage/env-123/public/nested.jpg" ); // @ts-expect-error - items is an array of unknown types expect(result.level1.level2.level3.level4.level5.items[0].nested.url.startsWith("http")).toBe(true); // @ts-expect-error - items is an array of unknown types expect(result.level1.level2.level3.level4.level5.items[0].nested.label).toBe("keep me"); expect(result.level1.level2.level3.level4.level5.items[1]).toBe("plain string"); expect(result.level1.level2.level3.level4.level5.items[2]).toBe(42); expect(result.level1.level2.level3.level4.level5.items[3]).toBeNull(); expect(result.level1.level2.level3.sibling).toContain("/storage/env-123/public/sibling.png"); expect(result.level1.level2.level3.sibling.startsWith("http")).toBe(true); expect(result.level1.untouched.a.b.c).toBe("no change"); }); test("should handle response data with file upload URLs", async () => { const { resolveStorageUrlsInObject: actual } = await vi.importActual("@/modules/storage/utils"); const responseData = { questionId1: "text answer", questionId2: 42, fileUploadId: ["/storage/env-123/public/doc.pdf", "/storage/env-123/public/img.png"], }; const result = actual(responseData); expect(result.questionId1).toBe("text answer"); expect(result.questionId2).toBe(42); const fileUrls = result.fileUploadId; expect(fileUrls[0]).toContain("/storage/env-123/public/doc.pdf"); expect(fileUrls[0].startsWith("http")).toBe(true); expect(fileUrls[1]).toContain("/storage/env-123/public/img.png"); expect(fileUrls[1].startsWith("http")).toBe(true); }); }); });