mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
test(security): cover url validation and form edge cases
Add and update tests for new security utilities and tightened UI behavior to prevent regressions in validation and error handling paths. Made-with: Cursor
This commit is contained in:
@@ -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}</>,
|
||||
}));
|
||||
|
||||
@@ -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(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const el = screen.getByRole("presentation");
|
||||
const el = screen.getByRole("img");
|
||||
expect(el.getAttribute("aria-label")).toContain("3");
|
||||
expect(el.getAttribute("aria-label")).toContain("5");
|
||||
});
|
||||
|
||||
@@ -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(<PageLink url="" />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders link with expected attributes and label", () => {
|
||||
render(<PageLink url="https://example.com" label="Visit site" className="custom-link" />);
|
||||
|
||||
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(<PageLink url="https://fallback.dev" label="" />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "https://fallback.dev" });
|
||||
|
||||
expect(link.getAttribute("href")).toBe("https://fallback.dev");
|
||||
});
|
||||
});
|
||||
@@ -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(<BrandIcon />);
|
||||
|
||||
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(<BrandIcon variant="icon" className="brand-class" loading="lazy" data-testid="brand" />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
|
||||
const { closeDialogMock, confirmMock } = vi.hoisted(() => ({
|
||||
closeDialogMock: vi.fn<() => void>(),
|
||||
confirmMock: vi.fn<ConfirmFn>(),
|
||||
}));
|
||||
|
||||
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<FieldValues> {
|
||||
return {
|
||||
formState: { isDirty, isSubmitting },
|
||||
} as UseFormReturn<FieldValues>;
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+58
-2
@@ -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<typeof fetch>().mockResolvedValue({
|
||||
blob: vi.fn<() => Promise<Blob>>().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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Blob>;
|
||||
|
||||
const buildDocumentMock = vi.fn<BuildDocument>();
|
||||
const toBlobMock = vi.fn<ToBlob>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("<div><ul><li>item</li></ul></div>")).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());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user