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("")).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);
+ });
+});