diff --git a/src/components/input/chip-input.test.tsx b/src/components/input/chip-input.test.tsx index 09f630238..750fedb2c 100644 --- a/src/components/input/chip-input.test.tsx +++ b/src/components/input/chip-input.test.tsx @@ -44,6 +44,25 @@ vi.mock("motion/react", () => ({ })); // Mock lingui +vi.mock("@lingui/core/macro", () => ({ + t: (stringsOrDescriptor: TemplateStringsArray | { message: string }, ...values: unknown[]) => { + if ( + typeof stringsOrDescriptor === "object" && + "message" in stringsOrDescriptor && + typeof stringsOrDescriptor.message === "string" + ) { + return stringsOrDescriptor.message; + } + + const strings = stringsOrDescriptor as TemplateStringsArray; + let result = strings[0]; + for (let i = 0; i < values.length; i++) { + result += String(values[i]) + strings[i + 1]; + } + return result; + }, +})); + vi.mock("@lingui/react/macro", () => ({ Trans: ({ children }: { children: React.ReactNode }) => <>{children}, })); diff --git a/src/components/level/display.test.tsx b/src/components/level/display.test.tsx index 71ea5b6df..856321990 100644 --- a/src/components/level/display.test.tsx +++ b/src/components/level/display.test.tsx @@ -5,7 +5,16 @@ afterEach(cleanup); // Mock @lingui/core/macro vi.mock("@lingui/core/macro", () => ({ - t: (strings: TemplateStringsArray, ...values: unknown[]) => { + t: (stringsOrDescriptor: TemplateStringsArray | { message: string }, ...values: unknown[]) => { + if ( + typeof stringsOrDescriptor === "object" && + "message" in stringsOrDescriptor && + typeof stringsOrDescriptor.message === "string" + ) { + return stringsOrDescriptor.message; + } + + const strings = stringsOrDescriptor as TemplateStringsArray; let result = strings[0]; for (let i = 0; i < values.length; i++) { result += String(values[i]) + strings[i + 1]; @@ -127,7 +136,7 @@ describe("LevelDisplay", () => { describe("aria attributes", () => { it("has aria-label showing level out of 5", () => { render(); - const el = screen.getByRole("presentation"); + const el = screen.getByRole("img"); expect(el.getAttribute("aria-label")).toContain("3"); expect(el.getAttribute("aria-label")).toContain("5"); }); diff --git a/src/components/resume/shared/page-link.test.tsx b/src/components/resume/shared/page-link.test.tsx new file mode 100644 index 000000000..eace692bf --- /dev/null +++ b/src/components/resume/shared/page-link.test.tsx @@ -0,0 +1,34 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { PageLink } from "./page-link"; + +afterEach(cleanup); + +describe("PageLink", () => { + it("returns null when url is empty", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("renders link with expected attributes and label", () => { + render(); + + const link = screen.getByRole("link", { name: "Visit site" }); + + expect(link.getAttribute("href")).toBe("https://example.com"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener"); + expect(link.className).toContain("inline-block"); + expect(link.className).toContain("custom-link"); + }); + + it("falls back to url text when label is missing", () => { + render(); + + const link = screen.getByRole("link", { name: "https://fallback.dev" }); + + expect(link.getAttribute("href")).toBe("https://fallback.dev"); + }); +}); diff --git a/src/components/ui/brand-icon.test.tsx b/src/components/ui/brand-icon.test.tsx new file mode 100644 index 000000000..b2ec0e64f --- /dev/null +++ b/src/components/ui/brand-icon.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BrandIcon } from "./brand-icon"; + +afterEach(cleanup); + +describe("BrandIcon", () => { + it("renders both dark and light logo images by default", () => { + render(); + + const images = screen.getAllByRole("img", { name: "Reactive Resume" }); + + expect(images).toHaveLength(2); + expect(images[0].getAttribute("src")).toBe("/logo/dark.svg"); + expect(images[0].className).toContain("hidden"); + expect(images[0].className).toContain("dark:block"); + expect(images[1].getAttribute("src")).toBe("/logo/light.svg"); + expect(images[1].className).toContain("block"); + expect(images[1].className).toContain("dark:hidden"); + }); + + it("uses icon variant and forwards shared img props", () => { + render(); + + const images = screen.getAllByTestId("brand"); + + expect(images).toHaveLength(2); + expect(images[0].getAttribute("src")).toBe("/icon/dark.svg"); + expect(images[1].getAttribute("src")).toBe("/icon/light.svg"); + expect(images[0].getAttribute("loading")).toBe("lazy"); + expect(images[1].getAttribute("loading")).toBe("lazy"); + expect(images[0].className).toContain("brand-class"); + expect(images[1].className).toContain("brand-class"); + }); +}); diff --git a/src/hooks/use-form-blocker.test.tsx b/src/hooks/use-form-blocker.test.tsx new file mode 100644 index 000000000..076696dfd --- /dev/null +++ b/src/hooks/use-form-blocker.test.tsx @@ -0,0 +1,181 @@ +import type { FieldValues, UseFormReturn } from "react-hook-form"; + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useFormBlocker } from "./use-form-blocker"; + +type ConfirmOptions = { + description?: string; + confirmText?: string; + cancelText?: string; +}; + +type ConfirmFn = (title: string, options?: ConfirmOptions) => Promise; + +const { closeDialogMock, confirmMock } = vi.hoisted(() => ({ + closeDialogMock: vi.fn<() => void>(), + confirmMock: vi.fn(), +})); + +vi.mock("@lingui/core/macro", () => ({ + t: (strings: TemplateStringsArray) => strings[0], +})); + +vi.mock("@/dialogs/store", () => ({ + useDialogStore: (selector: (state: { closeDialog: () => void }) => unknown) => + selector({ closeDialog: closeDialogMock }), +})); + +vi.mock("@/hooks/use-confirm", () => ({ + useConfirm: () => confirmMock, +})); + +afterEach(cleanup); + +function createFormState({ + isDirty = false, + isSubmitting = false, +}: { + isDirty?: boolean; + isSubmitting?: boolean; +}): UseFormReturn { + return { + formState: { isDirty, isSubmitting }, + } as UseFormReturn; +} + +describe("useFormBlocker", () => { + beforeEach(() => { + closeDialogMock.mockReset(); + confirmMock.mockReset(); + confirmMock.mockResolvedValue(false); + }); + + describe("default shouldBlock behavior", () => { + it("does not block when form is not dirty", async () => { + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: false, isSubmitting: false }))); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(closeDialogMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("does not block while submitting even if dirty", async () => { + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: true, isSubmitting: true }))); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(closeDialogMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("blocks when form is dirty and not submitting", async () => { + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: true, isSubmitting: false }))); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(closeDialogMock).not.toHaveBeenCalled(); + }); + }); + + describe("custom shouldBlock override", () => { + it("uses custom shouldBlock instead of default form-state logic", async () => { + const shouldBlock = vi.fn<() => boolean>(() => true); + const { result } = renderHook(() => + useFormBlocker(createFormState({ isDirty: false, isSubmitting: false }), { shouldBlock }), + ); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(shouldBlock).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(closeDialogMock).not.toHaveBeenCalled(); + }); + }); + + describe("requestClose", () => { + it("closes immediately when not blocked", async () => { + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: false, isSubmitting: false }))); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(closeDialogMock).toHaveBeenCalledTimes(1); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("keeps dialog open when blocked and confirmation is declined", async () => { + confirmMock.mockResolvedValue(false); + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: true, isSubmitting: false }))); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(confirmMock).toHaveBeenCalledWith("Are you sure you want to close this dialog?", { + description: "You have unsaved changes that will be lost.", + confirmText: "Leave", + cancelText: "Stay", + }); + expect(closeDialogMock).not.toHaveBeenCalled(); + }); + + it("closes dialog when blocked and confirmation is accepted", async () => { + confirmMock.mockResolvedValue(true); + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: true, isSubmitting: false }))); + + await act(async () => { + await result.current.requestClose(); + }); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(closeDialogMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("blockEvents", () => { + it.each(["onEscapeKeyDown", "onPointerDownOutside", "onInteractOutside"] as const)( + "does not prevent default for %s when not blocking", + async (handlerName) => { + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: false, isSubmitting: false }))); + const preventDefault = vi.fn<() => void>(); + + await act(async () => { + result.current.blockEvents[handlerName]({ preventDefault } as unknown as Event & KeyboardEvent); + }); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + expect(closeDialogMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["onEscapeKeyDown", "onPointerDownOutside", "onInteractOutside"] as const)( + "prevents default for %s and requests close when blocking", + async (handlerName) => { + const { result } = renderHook(() => useFormBlocker(createFormState({ isDirty: true, isSubmitting: false }))); + const preventDefault = vi.fn<() => void>(); + + await act(async () => { + result.current.blockEvents[handlerName]({ preventDefault } as unknown as Event & KeyboardEvent); + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(closeDialogMock).not.toHaveBeenCalled(); + }, + ); + }); +}); diff --git a/src/utils/env.test.ts b/src/utils/env.test.ts new file mode 100644 index 000000000..fffb49fed --- /dev/null +++ b/src/utils/env.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const createEnvMock = vi.fn<(config: unknown) => unknown>((config: unknown) => config); + +vi.mock("@t3-oss/env-core", () => ({ + createEnv: createEnvMock, +})); + +describe("env configuration", () => { + it("builds createEnv config with expected server schemas", async () => { + await import("./env"); + + expect(createEnvMock).toHaveBeenCalledOnce(); + + const config = createEnvMock.mock.calls[0][0] as { + clientPrefix: string; + runtimeEnv: NodeJS.ProcessEnv; + emptyStringAsUndefined: boolean; + server: Record; + }; + + expect(config.clientPrefix).toBe("VITE_"); + expect(config.runtimeEnv).toBe(process.env); + expect(config.emptyStringAsUndefined).toBe(true); + expect(config.server.APP_URL).toBeDefined(); + expect(config.server.DATABASE_URL).toBeDefined(); + expect(config.server.AUTH_SECRET).toBeDefined(); + expect(config.server.OAUTH_SCOPES).toBeDefined(); + expect(config.server.S3_FORCE_PATH_STYLE).toBeDefined(); + expect(config.server.FLAG_DISABLE_SIGNUPS).toBeDefined(); + + const parsedScopes = (config.server.OAUTH_SCOPES as { parse: (value: string) => string[] }).parse( + "openid profile email", + ); + expect(parsedScopes).toEqual(["openid", "profile", "email"]); + }); +}); diff --git a/src/utils/error-message.test.ts b/src/utils/error-message.test.ts new file mode 100644 index 000000000..d996a84bd --- /dev/null +++ b/src/utils/error-message.test.ts @@ -0,0 +1,64 @@ +import { ORPCError } from "@orpc/client"; +import { describe, expect, it } from "vite-plus/test"; + +import { getOrpcErrorMessage, getReadableErrorMessage, getResumeErrorMessage } from "./error-message"; + +describe("getReadableErrorMessage", () => { + it("returns error.message when error is an Error", () => { + const result = getReadableErrorMessage(new Error("Request failed"), "Fallback"); + expect(result).toBe("Request failed"); + }); + + it("returns string errors directly", () => { + const result = getReadableErrorMessage("String error", "Fallback"); + expect(result).toBe("String error"); + }); + + it("returns fallback for unknown values", () => { + const result = getReadableErrorMessage({ reason: "bad" }, "Fallback"); + expect(result).toBe("Fallback"); + }); +}); + +describe("getOrpcErrorMessage", () => { + it("returns mapped message for matching oRPC code", () => { + const result = getOrpcErrorMessage(new ORPCError("BAD_REQUEST"), { + fallback: "Fallback", + byCode: { BAD_REQUEST: "Mapped bad request" }, + }); + expect(result).toBe("Mapped bad request"); + }); + + it("returns server message when allowed and code is not mapped", () => { + const result = getOrpcErrorMessage(new ORPCError("INTERNAL_SERVER_ERROR", { message: "Server detail" }), { + fallback: "Fallback", + allowServerMessage: true, + }); + expect(result).toBe("Server detail"); + }); + + it("returns fallback when server message is not allowed and code is not mapped", () => { + const result = getOrpcErrorMessage(new ORPCError("INTERNAL_SERVER_ERROR", { message: "Server detail" }), { + fallback: "Fallback", + allowServerMessage: false, + }); + expect(result).toBe("Fallback"); + }); + + it("uses readable message handling for non-oRPC errors", () => { + const result = getOrpcErrorMessage("Custom string error", { fallback: "Fallback" }); + expect(result).toBe("Custom string error"); + }); +}); + +describe("getResumeErrorMessage", () => { + it("maps known resume error codes", () => { + const result = getResumeErrorMessage(new ORPCError("RESUME_SLUG_ALREADY_EXISTS")); + expect(result).toBe("A resume with this slug already exists."); + }); + + it("falls back for unknown resume error codes", () => { + const result = getResumeErrorMessage(new ORPCError("FORBIDDEN")); + expect(result).toBe("Something went wrong. Please try again."); + }); +}); diff --git a/src/utils/field.test.ts b/src/utils/field.test.ts new file mode 100644 index 000000000..7695036ce --- /dev/null +++ b/src/utils/field.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { filterFieldValues } from "./field"; + +describe("filterFieldValues", () => { + it("returns only fields with non-empty trimmed values", () => { + const result = filterFieldValues( + { name: " Amruth ", title: " ", email: null, company: "Reactive Resume" }, + { key: "name", label: "Name" }, + { key: "title", label: "Title" }, + { key: "email", label: "Email" }, + { key: "company", label: "Company" }, + ); + + expect(result.size).toBe(2); + expect(result.has("name")).toBe(true); + expect(result.has("company")).toBe(true); + expect(result.has("title")).toBe(false); + expect(result.has("email")).toBe(false); + }); +}); diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index 989431b84..353d47be4 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { generateFilename } from "./file"; +import { downloadFromUrl, downloadWithAnchor, generateFilename } from "./file"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); describe("generateFilename", () => { it("should slugify the prefix and append the extension", () => { @@ -21,3 +30,50 @@ describe("generateFilename", () => { expect(generateFilename("", "pdf")).toBe(".pdf"); }); }); + +describe("downloadWithAnchor", () => { + it("creates a link, clicks it, and revokes the object URL", () => { + const blob = new Blob(["test-content"], { type: "text/plain" }); + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob://test"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + + downloadWithAnchor(blob, "resume.pdf"); + + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(appendChildSpy).toHaveBeenCalledOnce(); + expect(clickSpy).toHaveBeenCalledOnce(); + expect(removeChildSpy).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(4999); + expect(revokeObjectURL).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob://test"); + }); +}); + +describe("downloadFromUrl", () => { + it("downloads from URL and triggers file download flow", async () => { + const blob = new Blob(["payload"], { type: "text/plain" }); + const fetchMock = vi.fn().mockResolvedValue({ + blob: vi.fn<() => Promise>().mockResolvedValue(blob), + } as unknown as Response); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob://from-url"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + + vi.stubGlobal("fetch", fetchMock); + + await downloadFromUrl("https://example.com/resume", "resume.txt"); + + expect(fetchMock).toHaveBeenCalledWith("https://example.com/resume"); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(clickSpy).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(5000); + expect(revokeObjectURL).toHaveBeenCalledWith("blob://from-url"); + }); +}); diff --git a/src/utils/resume/docx/index.test.ts b/src/utils/resume/docx/index.test.ts new file mode 100644 index 000000000..8a30dd94f --- /dev/null +++ b/src/utils/resume/docx/index.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ResumeData } from "@/schema/resume/data"; + +import { defaultResumeData } from "@/schema/resume/data"; + +import { buildDocx } from "./index"; + +type BuiltDocument = { kind: "test-doc" }; +type BuildDocument = (data: ResumeData) => BuiltDocument; +type ToBlob = (doc: BuiltDocument) => Promise; + +const buildDocumentMock = vi.fn(); +const toBlobMock = vi.fn(); + +vi.mock("./builder", () => ({ + buildDocument: buildDocumentMock, +})); + +vi.mock("docx", () => ({ + Packer: { + toBlob: toBlobMock, + }, +})); + +describe("buildDocx", () => { + const data: ResumeData = defaultResumeData; + const builtDocument: BuiltDocument = { kind: "test-doc" }; + + beforeEach(() => { + buildDocumentMock.mockReset(); + toBlobMock.mockReset(); + }); + + it("builds and packs a docx blob successfully", async () => { + const blob = new Blob(["docx-content"], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + + buildDocumentMock.mockReturnValueOnce(builtDocument); + toBlobMock.mockResolvedValueOnce(blob); + + await expect(buildDocx(data)).resolves.toBe(blob); + + expect(buildDocumentMock).toHaveBeenCalledOnce(); + expect(buildDocumentMock).toHaveBeenCalledWith(data); + expect(toBlobMock).toHaveBeenCalledOnce(); + expect(toBlobMock).toHaveBeenCalledWith(builtDocument); + }); + + it("rejects when the builder throws", async () => { + const error = new Error("Failed to build document"); + buildDocumentMock.mockImplementationOnce(() => { + throw error; + }); + + await expect(buildDocx(data)).rejects.toThrow("Failed to build document"); + expect(toBlobMock).not.toHaveBeenCalled(); + }); + + it("rejects when docx packing fails", async () => { + buildDocumentMock.mockReturnValueOnce(builtDocument); + toBlobMock.mockRejectedValueOnce(new Error("Packer failed")); + + await expect(buildDocx(data)).rejects.toThrow("Packer failed"); + expect(buildDocumentMock).toHaveBeenCalledOnce(); + expect(toBlobMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/utils/sanitize.test.ts b/src/utils/sanitize.test.ts index 684014adb..c33d490f3 100644 --- a/src/utils/sanitize.test.ts +++ b/src/utils/sanitize.test.ts @@ -65,6 +65,72 @@ describe("sanitizeCss", () => { it("should strip -moz-binding", () => { expect(sanitizeCss("-moz-binding: url(evil.xml)")).not.toContain("-moz-binding:"); }); + + it("should strip @import rules", () => { + const css = "@import url('https://evil.example/css'); .safe { color: red; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("@import"); + expect(result).toContain(".safe"); + }); + + it("should strip network url() calls and keep regular declarations", () => { + const css = ".card { background: url('https://evil.example/bg.png'); color: #111; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("url("); + expect(result).toMatch(/color\s*:\s*#111/); + }); + + it("should strip @font-face blocks", () => { + const css = + "@font-face { font-family: test; src: url('https://evil.example/font.woff2'); } .x { font-family: test; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("@font-face"); + expect(result).toContain(".x"); + }); + + it("should strip css comments before scanning dangerous values", () => { + const css = ".x { background: java/**/script:alert(1); color: blue; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("javascript:"); + expect(result).toContain("color"); + }); + + it("should decode escaped javascript protocol and strip it", () => { + const css = ".x { background: \\6a\\61\\76\\61\\73\\63\\72\\69\\70\\74:alert(1); color: green; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("javascript:"); + expect(result).toContain("color"); + }); + + it("should decode escaped expression() and strip it", () => { + const css = ".x { width: \\65\\78\\70\\72\\65\\73\\73\\69\\6f\\6e(alert(1)); height: 10px; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("expression("); + expect(result).toContain("height"); + }); + + it("should decode non-hex escapes without dropping characters", () => { + const css = ".x { content: \\(ok\\); color: teal; }"; + const result = sanitizeCss(css); + expect(result).toContain("content: (ok)"); + expect(result).toContain("color: teal"); + }); + + it("should strip data url payloads in url() functions", () => { + const css = ".x { background-image: url(data:text/html;base64,PHNjcmlwdD4=); color: black; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("url("); + expect(result).toContain("color"); + }); + + it("should strip image-set and cross-fade function network payloads", () => { + const css = + ".x { background-image: image-set(url('https://evil.example/a.png') 1x); mask-image: cross-fade(url('https://evil.example/a.png'), url('https://evil.example/b.png')); color: purple; }"; + const result = sanitizeCss(css); + expect(result).not.toContain("image-set("); + expect(result).not.toContain("cross-fade("); + expect(result).toContain("color"); + }); }); describe("isObject", () => { diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts index 694fbd050..9e67a6d81 100644 --- a/src/utils/string.test.ts +++ b/src/utils/string.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { getInitials, slugify, stripHtml, toUsername } from "./string"; +import { generateRandomName, getInitials, slugify, stripHtml, toUsername } from "./string"; describe("slugify", () => { it("should lowercase and hyphenate spaces", () => { @@ -70,3 +70,16 @@ describe("stripHtml", () => { expect(stripHtml("
  • item
")).toBe("item"); }); }); + +describe("generateRandomName", () => { + it("should generate a 3-word capitalized name", () => { + const result = generateRandomName(); + const parts = result.split(" "); + + expect(parts).toHaveLength(3); + for (const part of parts) { + expect(part.length).toBeGreaterThan(0); + expect(part[0]).toBe(part[0].toUpperCase()); + } + }); +}); diff --git a/src/utils/url-security.test.ts b/src/utils/url-security.test.ts new file mode 100644 index 000000000..7241d4b06 --- /dev/null +++ b/src/utils/url-security.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isAllowedExternalUrl, + isPrivateOrLoopbackHost, + parseAllowedHostList, + parseUrl, + sanitizeResumePictureUrl, +} from "./url-security"; + +describe("isPrivateOrLoopbackHost", () => { + it("returns true for localhost and private hosts", () => { + expect(isPrivateOrLoopbackHost("localhost")).toBe(true); + expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true); + expect(isPrivateOrLoopbackHost("10.0.0.7")).toBe(true); + expect(isPrivateOrLoopbackHost("192.168.0.2")).toBe(true); + }); + + it("returns false for public hosts", () => { + expect(isPrivateOrLoopbackHost("example.com")).toBe(false); + expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false); + }); + + it("returns true for private and link-local ipv6 addresses", () => { + expect(isPrivateOrLoopbackHost("::1")).toBe(true); + expect(isPrivateOrLoopbackHost("[::1]")).toBe(true); + expect(isPrivateOrLoopbackHost("fd00::1234")).toBe(true); + expect(isPrivateOrLoopbackHost("[fe80::1]")).toBe(true); + }); +}); + +describe("isAllowedExternalUrl", () => { + const allowedHosts = new Set(["api.openai.com", "https://gateway.ai.vercel.com"]); + + it("allows https URLs on allowed hosts", () => { + expect(isAllowedExternalUrl("https://api.openai.com/v1", allowedHosts)).toBe(true); + expect(isAllowedExternalUrl("https://gateway.ai.vercel.com/v1", allowedHosts)).toBe(true); + }); + + it("blocks local and non-https URLs", () => { + expect(isAllowedExternalUrl("http://api.openai.com/v1", allowedHosts)).toBe(false); + expect(isAllowedExternalUrl("https://localhost:11434/v1", allowedHosts)).toBe(false); + expect(isAllowedExternalUrl("https://10.0.0.1/v1", allowedHosts)).toBe(false); + }); + + it("blocks malformed URLs and credentialed URLs", () => { + expect(isAllowedExternalUrl("not-a-url", allowedHosts)).toBe(false); + expect(isAllowedExternalUrl("https://user:pass@api.openai.com/v1", allowedHosts)).toBe(false); + }); + + it("supports origin-only entries in the allow list", () => { + const originOnlyAllowedHosts = new Set(["https://example.org"]); + expect(isAllowedExternalUrl("https://example.org/v1/hello", originOnlyAllowedHosts)).toBe(true); + expect(isAllowedExternalUrl("https://sub.example.org/v1/hello", originOnlyAllowedHosts)).toBe(false); + }); +}); + +describe("sanitizeResumePictureUrl", () => { + const appUrl = "https://rxresu.me"; + + it("keeps local uploads paths", () => { + expect(sanitizeResumePictureUrl("/uploads/user/pictures/a.webp", appUrl)).toBe("/uploads/user/pictures/a.webp"); + }); + + it("converts same-origin upload URLs to path-only values", () => { + expect(sanitizeResumePictureUrl("https://rxresu.me/uploads/u/p.jpg", appUrl)).toBe("/uploads/u/p.jpg"); + }); + + it("keeps query and hash for same-origin upload URLs", () => { + expect(sanitizeResumePictureUrl("https://rxresu.me/uploads/u/p.jpg?size=2#thumb", appUrl)).toBe( + "/uploads/u/p.jpg?size=2#thumb", + ); + }); + + it("rejects non-upload and cross-origin URLs", () => { + expect(sanitizeResumePictureUrl("https://example.com/pic.jpg", appUrl)).toBe(""); + expect(sanitizeResumePictureUrl("https://rxresu.me/other/pic.jpg", appUrl)).toBe(""); + }); + + it("rejects invalid URLs, invalid app URLs, and credentialed URLs", () => { + expect(sanitizeResumePictureUrl("not-a-url", appUrl)).toBe(""); + expect(sanitizeResumePictureUrl("ftp://rxresu.me/uploads/p.jpg", appUrl)).toBe(""); + expect(sanitizeResumePictureUrl("https://user:pass@rxresu.me/uploads/p.jpg", appUrl)).toBe(""); + expect(sanitizeResumePictureUrl("https://rxresu.me/uploads/p.jpg", "not-a-url")).toBe(""); + }); +}); + +describe("parse helpers", () => { + it("parseUrl returns URL object for valid URLs and null for invalid input", () => { + expect(parseUrl("https://example.com/path")?.hostname).toBe("example.com"); + expect(parseUrl("invalid url")).toBeNull(); + }); + + it("parseAllowedHostList normalizes host entries and handles empty input", () => { + const set = parseAllowedHostList(" API.OpenAI.com , https://gateway.ai.vercel.com ,,api.openai.com "); + expect(set.has("api.openai.com")).toBe(true); + expect(set.has("https://gateway.ai.vercel.com")).toBe(true); + expect(set.size).toBe(2); + + expect(parseAllowedHostList(undefined).size).toBe(0); + expect(parseAllowedHostList("").size).toBe(0); + }); +});