mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
test: add unit and component tests across the monorepo
Adds ~1000 tests to bring the previously-untested packages and apps under coverage: - packages/utils — string, color, date, file, level, locale, sanitize, field, html, network-icons, rate-limit, url, url-security, monorepo, resume/patch, resume/docx/link-utils, style helpers (~97% on the testable utility files) - packages/ui — 28 component test files plus the use-controlled-state, use-mobile, use-confirm, use-prompt hooks (95% statements) - packages/pdf — shared template helpers (filtering, columns, picture, metrics, section-links, page-size, rich-text-html, section-title) - packages/schema — resumeDataSchema, page, templates, default - packages/fonts — expanded coverage on font helpers - packages/ai — resume sanitize and extraction template - packages/api — resume-access-policy - apps/web — error-message, locale, theme, pwa, dialogs/store, and the resume/move-item / section-actions / make-section-item helpers Adds jsdom polyfills (ResizeObserver, IntersectionObserver, scrollIntoView, matchMedia) and an explicit React Testing Library cleanup hook to vitest.setup.ts so portal- and overlay-based components work without per-test setup.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseColorString, rgbaStringToHex } from "./color";
|
||||
|
||||
describe("rgbaStringToHex", () => {
|
||||
it("converts opaque rgb to hex", () => {
|
||||
expect(rgbaStringToHex("rgb(255, 0, 0)")).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("converts opaque rgba to hex (alpha not represented)", () => {
|
||||
expect(rgbaStringToHex("rgba(0, 255, 0, 1)")).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("converts black", () => {
|
||||
expect(rgbaStringToHex("rgb(0, 0, 0)")).toBe("#000000");
|
||||
});
|
||||
|
||||
it("converts white", () => {
|
||||
expect(rgbaStringToHex("rgb(255, 255, 255)")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("converts arbitrary mid-range color", () => {
|
||||
expect(rgbaStringToHex("rgb(128, 64, 200)")).toBe("#8040c8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseColorString", () => {
|
||||
describe("rgb format", () => {
|
||||
it("parses rgb without alpha as alpha=1", () => {
|
||||
expect(parseColorString("rgb(255, 128, 64)")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
|
||||
});
|
||||
|
||||
it("parses rgba with alpha", () => {
|
||||
expect(parseColorString("rgba(10, 20, 30, 0.5)")).toEqual({ r: 10, g: 20, b: 30, a: 0.5 });
|
||||
});
|
||||
|
||||
it("parses rgba with alpha=0", () => {
|
||||
expect(parseColorString("rgba(0, 0, 0, 0)")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
|
||||
});
|
||||
|
||||
it("handles extra whitespace in rgb", () => {
|
||||
expect(parseColorString("rgb( 255 , 0 , 0 )")).toEqual({ r: 255, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(parseColorString(" rgb(1, 2, 3) ")).toEqual({ r: 1, g: 2, b: 3, a: 1 });
|
||||
});
|
||||
|
||||
it("returns null for malformed rgb", () => {
|
||||
expect(parseColorString("rgb(255, 0)")).toBeNull();
|
||||
expect(parseColorString("rgb(a, b, c)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hex format", () => {
|
||||
it("parses 6-digit hex", () => {
|
||||
expect(parseColorString("#ff8040")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
|
||||
});
|
||||
|
||||
it("parses 6-digit hex uppercase", () => {
|
||||
expect(parseColorString("#FF8040")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
|
||||
});
|
||||
|
||||
it("parses 3-digit hex by doubling each digit", () => {
|
||||
expect(parseColorString("#f80")).toEqual({ r: 0xff, g: 0x88, b: 0x00, a: 1 });
|
||||
});
|
||||
|
||||
it("parses #000 as black", () => {
|
||||
expect(parseColorString("#000")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses #fff as white", () => {
|
||||
expect(parseColorString("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("returns null for hex without #", () => {
|
||||
expect(parseColorString("ff0000")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid hex length", () => {
|
||||
expect(parseColorString("#ff00")).toBeNull();
|
||||
expect(parseColorString("#ff00ff00")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hex with non-hex chars", () => {
|
||||
expect(parseColorString("#zz0000")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid input", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseColorString("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for plain color names", () => {
|
||||
expect(parseColorString("red")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hsl format", () => {
|
||||
expect(parseColorString("hsl(0, 100%, 50%)")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDate, formatPeriod, formatSingleDate } from "./date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats YYYY-MM as 'Month Year'", () => {
|
||||
expect(formatDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD as 'Month Year' by default (day excluded)", () => {
|
||||
expect(formatDate("2024-03-15")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD with includeDay=true as 'Month DD, Year'", () => {
|
||||
expect(formatDate("2024-03-15", true)).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("returns YYYY unchanged when only year provided", () => {
|
||||
expect(formatDate("2024")).toBe("2024");
|
||||
});
|
||||
|
||||
it("handles January (month 1)", () => {
|
||||
expect(formatDate("2024-01")).toBe("January 2024");
|
||||
});
|
||||
|
||||
it("handles December (month 12)", () => {
|
||||
expect(formatDate("2024-12")).toBe("December 2024");
|
||||
});
|
||||
|
||||
it("includes day even with single-digit days", () => {
|
||||
expect(formatDate("2024-03-05", true)).toBe("March 05, 2024");
|
||||
});
|
||||
|
||||
it("returns 'undefined' month name when out-of-range month is supplied", () => {
|
||||
// Defensive: behavior documents what happens with bad input
|
||||
expect(formatDate("2024-13")).toBe("undefined 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriod", () => {
|
||||
it("returns empty string when both dates missing", () => {
|
||||
expect(formatPeriod()).toBe("");
|
||||
});
|
||||
|
||||
it("returns end date alone when start is missing", () => {
|
||||
expect(formatPeriod(undefined, "2024-05")).toBe("2024-05");
|
||||
});
|
||||
|
||||
it("returns start - Present when end is missing", () => {
|
||||
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("formats both ends when both provided", () => {
|
||||
expect(formatPeriod("2020-06", "2024-03")).toBe("June 2020 - March 2024");
|
||||
});
|
||||
|
||||
it("treats empty string start same as undefined", () => {
|
||||
expect(formatPeriod("", "2024-05")).toBe("2024-05");
|
||||
});
|
||||
|
||||
it("treats empty string end same as undefined", () => {
|
||||
expect(formatPeriod("2024-01", "")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("returns empty when start empty and end empty", () => {
|
||||
expect(formatPeriod("", "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleDate", () => {
|
||||
it("returns empty string when date is undefined", () => {
|
||||
expect(formatSingleDate()).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when date is empty string", () => {
|
||||
expect(formatSingleDate("")).toBe("");
|
||||
});
|
||||
|
||||
it("formats full date with day included", () => {
|
||||
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("formats year-month even with includeDay flag implied", () => {
|
||||
expect(formatSingleDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("returns plain year when only year present", () => {
|
||||
expect(formatSingleDate("2024")).toBe("2024");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterFieldValues } from "./field";
|
||||
|
||||
describe("filterFieldValues", () => {
|
||||
it("returns map of fields whose values are non-empty", () => {
|
||||
const fields = [{ key: "name" as const }, { key: "email" as const }];
|
||||
const result = filterFieldValues({ name: "Alice", email: "alice@example.com" }, ...fields);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("name")).toEqual({ key: "name" });
|
||||
expect(result.get("email")).toEqual({ key: "email" });
|
||||
});
|
||||
|
||||
it("filters out fields with empty string values", () => {
|
||||
const fields = [{ key: "name" as const }, { key: "email" as const }];
|
||||
const result = filterFieldValues({ name: "Alice", email: "" }, ...fields);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("email")).toBe(false);
|
||||
expect(result.has("name")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters out fields with whitespace-only values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: " " }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with null values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: null }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with undefined values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: undefined }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with missing keys", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({}, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves additional field properties on output", () => {
|
||||
const fields = [{ key: "name" as const, label: "Name", icon: "person" }];
|
||||
const result = filterFieldValues({ name: "Alice" }, ...fields);
|
||||
|
||||
expect(result.get("name")).toEqual({ key: "name", label: "Name", icon: "person" });
|
||||
});
|
||||
|
||||
it("returns empty map when no fields supplied", () => {
|
||||
const result = filterFieldValues({ name: "Alice" });
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves field order in iteration", () => {
|
||||
const fields = [{ key: "a" as const }, { key: "b" as const }, { key: "c" as const }];
|
||||
const result = filterFieldValues({ a: "1", b: "2", c: "3" }, ...fields);
|
||||
|
||||
const keys = [...result.keys()];
|
||||
expect(keys).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "./file";
|
||||
|
||||
describe("generateFilename", () => {
|
||||
it("slugifies the prefix without extension", () => {
|
||||
expect(generateFilename("My Resume")).toBe("my-resume");
|
||||
});
|
||||
|
||||
it("appends extension when provided", () => {
|
||||
expect(generateFilename("My Resume", "pdf")).toBe("my-resume.pdf");
|
||||
});
|
||||
|
||||
it("appends extension verbatim (no extra dot)", () => {
|
||||
expect(generateFilename("Name", "json")).toBe("name.json");
|
||||
});
|
||||
|
||||
it("handles empty extension as no extension", () => {
|
||||
expect(generateFilename("foo", "")).toBe("foo");
|
||||
});
|
||||
|
||||
it("strips diacritics", () => {
|
||||
expect(generateFilename("Résumé", "pdf")).toBe("resume.pdf");
|
||||
});
|
||||
|
||||
it("handles empty prefix", () => {
|
||||
expect(generateFilename("", "pdf")).toBe(".pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadWithAnchor", () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let originalCreate: typeof URL.createObjectURL;
|
||||
let originalRevoke: typeof URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
originalCreate = URL.createObjectURL;
|
||||
originalRevoke = URL.revokeObjectURL;
|
||||
createObjectURLSpy = vi.fn(() => "blob:mock-url");
|
||||
revokeObjectURLSpy = vi.fn();
|
||||
URL.createObjectURL = createObjectURLSpy;
|
||||
URL.revokeObjectURL = revokeObjectURLSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
});
|
||||
|
||||
it("creates an anchor with correct href, rel, and download attributes", () => {
|
||||
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
downloadWithAnchor(blob, "test.txt");
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("removes the anchor from the DOM after click", () => {
|
||||
const blob = new Blob(["hello"]);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
const beforeChildCount = document.body.childElementCount;
|
||||
downloadWithAnchor(blob, "test.txt");
|
||||
const afterChildCount = document.body.childElementCount;
|
||||
|
||||
expect(afterChildCount).toBe(beforeChildCount);
|
||||
});
|
||||
|
||||
it("revokes the object URL after 5 seconds", () => {
|
||||
const blob = new Blob(["hello"]);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
downloadWithAnchor(blob, "test.txt");
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadFromUrl", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let originalCreate: typeof URL.createObjectURL;
|
||||
let originalRevoke: typeof URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
originalCreate = URL.createObjectURL;
|
||||
originalRevoke = URL.revokeObjectURL;
|
||||
createObjectURLSpy = vi.fn(() => "blob:mock-url");
|
||||
revokeObjectURLSpy = vi.fn();
|
||||
URL.createObjectURL = createObjectURLSpy;
|
||||
URL.revokeObjectURL = revokeObjectURLSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
});
|
||||
|
||||
it("fetches the URL and downloads the resulting blob", async () => {
|
||||
const mockBlob = new Blob(["data"], { type: "application/pdf" });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(mockBlob, { status: 200 })),
|
||||
) as unknown as typeof global.fetch;
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await downloadFromUrl("https://example.com/file.pdf", "file.pdf");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("https://example.com/file.pdf");
|
||||
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates fetch errors", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network down"))) as unknown as typeof global.fetch;
|
||||
await expect(downloadFromUrl("https://example.com", "x.pdf")).rejects.toThrow("network down");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arrayToHtmlList, toHtmlDescription } from "./html";
|
||||
|
||||
describe("toHtmlDescription", () => {
|
||||
it("returns empty string when no inputs provided", () => {
|
||||
expect(toHtmlDescription()).toBe("");
|
||||
});
|
||||
|
||||
it("returns just <p> with summary when no highlights", () => {
|
||||
expect(toHtmlDescription("Summary here")).toBe("<p>Summary here</p>");
|
||||
});
|
||||
|
||||
it("returns just <ul> when only highlights", () => {
|
||||
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("returns combined output with both summary and highlights", () => {
|
||||
expect(toHtmlDescription("Sum", ["a"])).toBe("<p>Sum</p><ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("omits ul when highlights is empty array", () => {
|
||||
expect(toHtmlDescription("Sum", [])).toBe("<p>Sum</p>");
|
||||
});
|
||||
|
||||
it("preserves order: summary first, list second", () => {
|
||||
const result = toHtmlDescription("S", ["x", "y", "z"]);
|
||||
expect(result.indexOf("<p>")).toBeLessThan(result.indexOf("<ul>"));
|
||||
});
|
||||
|
||||
it("treats empty string summary as falsy", () => {
|
||||
expect(toHtmlDescription("", ["a"])).toBe("<ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("does not escape HTML in inputs (caller's responsibility)", () => {
|
||||
// Document existing behavior: caller must sanitize before passing
|
||||
expect(toHtmlDescription("<script>")).toBe("<p><script></p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrayToHtmlList", () => {
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(arrayToHtmlList([])).toBe("");
|
||||
});
|
||||
|
||||
it("renders single item list", () => {
|
||||
expect(arrayToHtmlList(["one"])).toBe("<ul><li>one</li></ul>");
|
||||
});
|
||||
|
||||
it("renders multi-item list", () => {
|
||||
expect(arrayToHtmlList(["one", "two", "three"])).toBe("<ul><li>one</li><li>two</li><li>three</li></ul>");
|
||||
});
|
||||
|
||||
it("preserves item order", () => {
|
||||
const result = arrayToHtmlList(["c", "a", "b"]);
|
||||
expect(result.indexOf("c")).toBeLessThan(result.indexOf("a"));
|
||||
expect(result.indexOf("a")).toBeLessThan(result.indexOf("b"));
|
||||
});
|
||||
|
||||
it("does not escape HTML in items", () => {
|
||||
expect(arrayToHtmlList(["<b>bold</b>"])).toBe("<ul><li><b>bold</b></li></ul>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLevel } from "./level";
|
||||
|
||||
describe("parseLevel", () => {
|
||||
describe("invalid input", () => {
|
||||
it("returns 0 for undefined", () => {
|
||||
expect(parseLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for empty string", () => {
|
||||
expect(parseLevel("")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for unrecognized text", () => {
|
||||
expect(parseLevel("hello world")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric values", () => {
|
||||
it("returns 0 for '0'", () => {
|
||||
expect(parseLevel("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 5 for '5'", () => {
|
||||
expect(parseLevel("5")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 3 for '3'", () => {
|
||||
expect(parseLevel("3")).toBe(3);
|
||||
});
|
||||
|
||||
it("falls through to text matching for out-of-range numerics", () => {
|
||||
// 6 is out of range so the numeric branch fails, no text matches → 0
|
||||
expect(parseLevel("6")).toBe(0);
|
||||
});
|
||||
|
||||
it("falls through for negative numerics", () => {
|
||||
expect(parseLevel("-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("parses leading-numeric strings via parseInt", () => {
|
||||
// "3 stars" → parseInt yields 3, in [0,5] → returns 3
|
||||
expect(parseLevel("3 stars")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text levels", () => {
|
||||
it("returns 5 for 'native'", () => {
|
||||
expect(parseLevel("native")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 5 for 'expert'", () => {
|
||||
expect(parseLevel("Expert")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 5 for 'master'", () => {
|
||||
expect(parseLevel("master")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 4 for 'fluent'", () => {
|
||||
expect(parseLevel("fluent")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 4 for 'advanced'", () => {
|
||||
expect(parseLevel("Advanced")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 4 for 'proficient'", () => {
|
||||
expect(parseLevel("proficient")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 3 for 'intermediate'", () => {
|
||||
expect(parseLevel("intermediate")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 3 for 'conversational'", () => {
|
||||
expect(parseLevel("conversational")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 2 for 'beginner'", () => {
|
||||
expect(parseLevel("beginner")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 2 for 'basic'", () => {
|
||||
expect(parseLevel("basic")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 2 for 'elementary'", () => {
|
||||
expect(parseLevel("Elementary")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 1 for 'novice'", () => {
|
||||
expect(parseLevel("novice")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CEFR levels", () => {
|
||||
it("returns 5 for C2", () => {
|
||||
expect(parseLevel("C2")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 4 for C1", () => {
|
||||
expect(parseLevel("c1")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 3 for B2", () => {
|
||||
expect(parseLevel("B2")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 2 for B1", () => {
|
||||
expect(parseLevel("b1")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 1 for A2", () => {
|
||||
expect(parseLevel("A2")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 for A1", () => {
|
||||
expect(parseLevel("a1")).toBe(1);
|
||||
});
|
||||
|
||||
it("matches CEFR levels embedded in surrounding text", () => {
|
||||
expect(parseLevel("Level: B2")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority of matchers", () => {
|
||||
it("expert beats CEFR when both substrings appear", () => {
|
||||
// "expert" matches first and short-circuits before CEFR check
|
||||
expect(parseLevel("expert C2")).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultLocale, isLocale } from "./locale";
|
||||
|
||||
describe("defaultLocale", () => {
|
||||
it("is en-US", () => {
|
||||
expect(defaultLocale).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocale", () => {
|
||||
it("returns true for non-empty string", () => {
|
||||
expect(isLocale("en-US")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for any non-empty string (no validation of locale shape)", () => {
|
||||
expect(isLocale("xyz")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isLocale("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for number", () => {
|
||||
expect(isLocale(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isLocale(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isLocale(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for object", () => {
|
||||
expect(isLocale({})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for array", () => {
|
||||
expect(isLocale([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { findWorkspaceRoot, getLocalDataDirectory } from "./monorepo.node";
|
||||
|
||||
describe("findWorkspaceRoot", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Resolve symlinks so comparisons match findWorkspaceRoot's realpathSync output.
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "rr-monorepo-test-")));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the directory containing pnpm-workspace.yaml", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(findWorkspaceRoot(tempDir)).toBe(tempDir);
|
||||
});
|
||||
|
||||
it("walks up directories to find pnpm-workspace.yaml", () => {
|
||||
const nested = join(tempDir, "apps", "web");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(findWorkspaceRoot(nested)).toBe(tempDir);
|
||||
});
|
||||
|
||||
it("returns null if no workspace manifest is found", () => {
|
||||
// Use a temp dir that has no pnpm-workspace.yaml above it. Any descendant of /tmp
|
||||
// either has the file or eventually hits /. We use a deep-enough sibling here:
|
||||
const isolated = join(tempDir, "isolated");
|
||||
mkdirSync(isolated, { recursive: true });
|
||||
// Walk up from isolated; nothing in tempDir has a workspace manifest.
|
||||
// findWorkspaceRoot will return null when it reaches the filesystem root.
|
||||
// Verify behavior: at minimum, it should not return our tempDir.
|
||||
const result = findWorkspaceRoot(isolated);
|
||||
expect(result).not.toBe(tempDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalDataDirectory", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "rr-data-test-")));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns override when provided", () => {
|
||||
expect(getLocalDataDirectory("/custom/path")).toBe("/custom/path");
|
||||
});
|
||||
|
||||
it("returns workspaceRoot/data when manifest is found", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(getLocalDataDirectory(undefined, tempDir)).toBe(join(tempDir, "data"));
|
||||
});
|
||||
|
||||
it("falls back to cwd/data when no manifest found", () => {
|
||||
const isolated = join(tempDir, "isolated");
|
||||
mkdirSync(isolated, { recursive: true });
|
||||
|
||||
const result = getLocalDataDirectory(undefined, isolated);
|
||||
// Should end with /data and not be the manifest workspace path
|
||||
expect(result.endsWith("data")).toBe(true);
|
||||
});
|
||||
|
||||
it("override takes precedence even if workspace exists", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(getLocalDataDirectory("/elsewhere", tempDir)).toBe("/elsewhere");
|
||||
});
|
||||
|
||||
it("manifest file actually exists for the test setup", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
expect(existsSync(join(tempDir, "pnpm-workspace.yaml"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNetworkIcon } from "./network-icons";
|
||||
|
||||
describe("getNetworkIcon", () => {
|
||||
it("returns 'star' default when network is undefined", () => {
|
||||
expect(getNetworkIcon()).toBe("star");
|
||||
});
|
||||
|
||||
it("returns 'star' default when network is empty string", () => {
|
||||
expect(getNetworkIcon("")).toBe("star");
|
||||
});
|
||||
|
||||
it("returns 'star' default when no match found", () => {
|
||||
expect(getNetworkIcon("unknown-network")).toBe("star");
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
expect(getNetworkIcon("GitHub")).toBe("github-logo");
|
||||
expect(getNetworkIcon("LINKEDIN")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("matches GitHub", () => {
|
||||
expect(getNetworkIcon("github")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("matches LinkedIn", () => {
|
||||
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("matches Twitter and X variants", () => {
|
||||
expect(getNetworkIcon("twitter")).toBe("twitter-logo");
|
||||
// "x" alone matches, including in unrelated words
|
||||
expect(getNetworkIcon("x")).toBe("twitter-logo");
|
||||
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
|
||||
});
|
||||
|
||||
it("matches Facebook", () => {
|
||||
expect(getNetworkIcon("facebook")).toBe("facebook-logo");
|
||||
});
|
||||
|
||||
it("matches Instagram", () => {
|
||||
expect(getNetworkIcon("instagram")).toBe("instagram-logo");
|
||||
});
|
||||
|
||||
it("matches YouTube", () => {
|
||||
expect(getNetworkIcon("youtube")).toBe("youtube-logo");
|
||||
});
|
||||
|
||||
it("matches Stack Overflow variants", () => {
|
||||
expect(getNetworkIcon("stackoverflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("stack-overflow")).toBe("stack-overflow-logo");
|
||||
});
|
||||
|
||||
it("matches Medium", () => {
|
||||
expect(getNetworkIcon("medium")).toBe("medium-logo");
|
||||
});
|
||||
|
||||
it("matches Dev.to variants to 'code'", () => {
|
||||
expect(getNetworkIcon("dev.to")).toBe("code");
|
||||
expect(getNetworkIcon("devto")).toBe("code");
|
||||
});
|
||||
|
||||
it("matches Dribbble", () => {
|
||||
expect(getNetworkIcon("dribbble")).toBe("dribbble-logo");
|
||||
});
|
||||
|
||||
it("matches Behance", () => {
|
||||
expect(getNetworkIcon("behance")).toBe("behance-logo");
|
||||
});
|
||||
|
||||
it("matches GitLab to 'git-branch'", () => {
|
||||
expect(getNetworkIcon("gitlab")).toBe("git-branch");
|
||||
});
|
||||
|
||||
it("matches Bitbucket and CodePen to 'code'", () => {
|
||||
expect(getNetworkIcon("bitbucket")).toBe("code");
|
||||
expect(getNetworkIcon("codepen")).toBe("code");
|
||||
});
|
||||
|
||||
it("matches when keyword is substring of input", () => {
|
||||
expect(getNetworkIcon("My GitHub Profile")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("returns first matching icon (priority order)", () => {
|
||||
// "github" comes before "x" in the map, so github wins
|
||||
// (and this is also how it should behave for ambiguous strings)
|
||||
expect(getNetworkIcon("xgithub")).toBe("github-logo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "./rate-limit";
|
||||
|
||||
describe("TRUSTED_IP_HEADERS", () => {
|
||||
it("includes Cloudflare connecting headers", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toContain("CF-Connecting-IP");
|
||||
expect(TRUSTED_IP_HEADERS).toContain("CF-Connecting-IPv6");
|
||||
});
|
||||
|
||||
it("includes True-Client-IP", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toContain("True-Client-IP");
|
||||
});
|
||||
|
||||
it("includes X-Forwarded-For and X-Real-IP", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toContain("X-Forwarded-For");
|
||||
expect(TRUSTED_IP_HEADERS).toContain("X-Real-IP");
|
||||
});
|
||||
|
||||
it("contains exactly the documented set of trusted headers", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitConfig", () => {
|
||||
describe("betterAuth.global", () => {
|
||||
it("is enabled with sensible defaults", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.enabled).toBe(true);
|
||||
expect(rateLimitConfig.betterAuth.global.window).toBe(60);
|
||||
expect(rateLimitConfig.betterAuth.global.max).toBe(60);
|
||||
});
|
||||
|
||||
it("rate-limits sign-in/email more strictly than global default", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/sign-in/email"]).toEqual({ window: 60, max: 5 });
|
||||
});
|
||||
|
||||
it("rate-limits sign-up/email even more strictly", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/sign-up/email"]).toEqual({ window: 60, max: 3 });
|
||||
});
|
||||
|
||||
it("uses long window for password reset to deter abuse", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/request-password-reset"]).toEqual({
|
||||
window: 600,
|
||||
max: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("rate-limits 2FA verification with longer windows", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-otp"].window).toBe(600);
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-totp"].window).toBe(600);
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-backup-code"].window).toBe(600);
|
||||
});
|
||||
|
||||
it("allows generous polling for username availability", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/is-username-available"]).toEqual({
|
||||
window: 60,
|
||||
max: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterAuth.oauthProvider", () => {
|
||||
it("rate-limits register most strictly", () => {
|
||||
expect(rateLimitConfig.betterAuth.oauthProvider.register.max).toBe(5);
|
||||
});
|
||||
|
||||
it("allows higher introspect/userinfo throughput", () => {
|
||||
expect(rateLimitConfig.betterAuth.oauthProvider.introspect.max).toBe(60);
|
||||
expect(rateLimitConfig.betterAuth.oauthProvider.userinfo.max).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterAuth.apiKey", () => {
|
||||
it("is enabled with hourly window of 1000 requests", () => {
|
||||
expect(rateLimitConfig.betterAuth.apiKey.enabled).toBe(true);
|
||||
expect(rateLimitConfig.betterAuth.apiKey.timeWindow).toBe(60 * 60 * 1000);
|
||||
expect(rateLimitConfig.betterAuth.apiKey.maxRequests).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orpc", () => {
|
||||
it("limits resume password reset to 5 per 10 minutes", () => {
|
||||
expect(rateLimitConfig.orpc.resumePassword).toEqual({ maxRequests: 5, window: 10 * 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits PDF export to 5 per minute", () => {
|
||||
expect(rateLimitConfig.orpc.pdfExport).toEqual({ maxRequests: 5, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits AI requests to 20 per minute", () => {
|
||||
expect(rateLimitConfig.orpc.aiRequest).toEqual({ maxRequests: 20, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("provides reasonable mutation throughput for resume edits", () => {
|
||||
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 60, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits storage uploads more strictly than deletes", () => {
|
||||
expect(rateLimitConfig.orpc.storageUpload.maxRequests).toBeLessThan(
|
||||
rateLimitConfig.orpc.storageDelete.maxRequests,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
describe("toSafeDocxLink", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(toSafeDocxLink("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(toSafeDocxLink(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("accepts http URLs", () => {
|
||||
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("preserves path and query", () => {
|
||||
expect(toSafeDocxLink("https://example.com/p?a=1")).toBe("https://example.com/p?a=1");
|
||||
});
|
||||
|
||||
it("returns mailto: for valid email", () => {
|
||||
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("returns null for empty mailto:", () => {
|
||||
expect(toSafeDocxLink("mailto:")).toBeNull();
|
||||
expect(toSafeDocxLink("mailto: ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims email body within mailto:", () => {
|
||||
expect(toSafeDocxLink("mailto: user@example.com ")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("rejects javascript: protocol", () => {
|
||||
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects data: protocol", () => {
|
||||
expect(toSafeDocxLink("data:text/html,<script>alert(1)</script>")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects file: protocol", () => {
|
||||
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(toSafeDocxLink("not a url at all")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects ftp: protocol", () => {
|
||||
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { applyResumePatches, createResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
|
||||
|
||||
describe("jsonPatchOperationSchema", () => {
|
||||
it("validates add op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo", value: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires value for add", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates remove op without value", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "remove", path: "/foo" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates replace op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "replace", path: "/foo", value: 2 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates move op with from", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a", from: "/b" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires from for move", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates copy op with from", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "copy", path: "/a", from: "/b" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates test op with value", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "test", path: "/a", value: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "swap", path: "/a", value: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("permits any value type for add (unknown)", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/x", value: { nested: { array: [1] } } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResumePatches", () => {
|
||||
it("returns empty array when documents are equal", () => {
|
||||
const patches = createResumePatches(defaultResumeData, defaultResumeData);
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a replace op when a scalar field changes", () => {
|
||||
const next = { ...defaultResumeData, basics: { ...defaultResumeData.basics, name: "Alice" } };
|
||||
const patches = createResumePatches(defaultResumeData, next);
|
||||
|
||||
expect(patches.some((p) => p.op === "replace" && p.path === "/basics/name")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures multiple changes in distinct operations", () => {
|
||||
const next = {
|
||||
...defaultResumeData,
|
||||
basics: { ...defaultResumeData.basics, name: "Alice", email: "alice@example.com" },
|
||||
};
|
||||
const patches = createResumePatches(defaultResumeData, next);
|
||||
|
||||
expect(patches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResumePatches", () => {
|
||||
it("applies a single replace op", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
// Original is preserved (not mutated)
|
||||
expect(defaultResumeData.basics.name).toBe("");
|
||||
});
|
||||
|
||||
it("does not mutate the input data", () => {
|
||||
const before = JSON.stringify(defaultResumeData);
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Bob" }]);
|
||||
expect(JSON.stringify(defaultResumeData)).toBe(before);
|
||||
});
|
||||
|
||||
it("applies multiple ops in sequence", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [
|
||||
{ op: "replace", path: "/basics/name", value: "Alice" },
|
||||
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
expect(result.basics.email).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for unresolvable path", () => {
|
||||
expect(() =>
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/does/not/exist", value: "x" }]),
|
||||
).toThrow(ResumePatchError);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for failed test op", () => {
|
||||
try {
|
||||
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong-value" }]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResumePatchError);
|
||||
const patchError = error as ResumePatchError;
|
||||
expect(patchError.code).toBe("TEST_OPERATION_FAILED");
|
||||
expect(patchError.index).toBe(0);
|
||||
expect(patchError.message).toContain("Test operation failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the failing operation in the error", () => {
|
||||
try {
|
||||
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong" }]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as ResumePatchError).operation).toEqual({
|
||||
op: "test",
|
||||
path: "/basics/name",
|
||||
value: "wrong",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back if patched document fails schema validation", () => {
|
||||
// Setting picture.size to a value outside schema bounds (min 32, max 512)
|
||||
expect(() =>
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/picture/size", value: 9999 }]),
|
||||
).toThrow(/Patch produced invalid resume data/);
|
||||
});
|
||||
|
||||
it("supports adding to arrays", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [
|
||||
{
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0]?.name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("supports remove operation on existing path", () => {
|
||||
// First add an item, then remove it
|
||||
const withItem = applyResumePatches(defaultResumeData, [
|
||||
{
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const removed = applyResumePatches(withItem, [{ op: "remove", path: "/sections/skills/items/0" }]);
|
||||
expect(removed.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResumePatchError", () => {
|
||||
it("captures all expected properties", () => {
|
||||
const error = new ResumePatchError("CODE", "msg", 2, { op: "test", path: "/x", value: 1 });
|
||||
expect(error.name).toBe("ResumePatchError");
|
||||
expect(error.code).toBe("CODE");
|
||||
expect(error.message).toBe("msg");
|
||||
expect(error.index).toBe(2);
|
||||
expect(error.operation).toEqual({ op: "test", path: "/x", value: 1 });
|
||||
});
|
||||
|
||||
it("is instanceof Error", () => {
|
||||
const error = new ResumePatchError("X", "y", 0, { op: "remove", path: "/x" });
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isObject, sanitizeCss, sanitizeHtml } from "./sanitize";
|
||||
|
||||
describe("sanitizeHtml", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves allowed tags", () => {
|
||||
const result = sanitizeHtml("<p>Hello <strong>world</strong></p>");
|
||||
expect(result).toContain("<p>");
|
||||
expect(result).toContain("<strong>");
|
||||
expect(result).toContain("Hello");
|
||||
expect(result).toContain("world");
|
||||
});
|
||||
|
||||
it("strips disallowed tags like <script>", () => {
|
||||
const result = sanitizeHtml("<p>Hello</p><script>alert(1)</script>");
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
expect(result).toContain("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("strips disallowed attributes like onerror", () => {
|
||||
const result = sanitizeHtml('<img src="x" onerror="alert(1)" />');
|
||||
expect(result).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("strips iframe and object tags", () => {
|
||||
expect(sanitizeHtml("<iframe src='evil'></iframe>")).not.toContain("iframe");
|
||||
expect(sanitizeHtml("<object data='evil'></object>")).not.toContain("<object");
|
||||
});
|
||||
|
||||
it("preserves links with safe href", () => {
|
||||
const result = sanitizeHtml('<a href="https://example.com">Click</a>');
|
||||
expect(result).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("blocks javascript: URLs in href", () => {
|
||||
const result = sanitizeHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("preserves table structure tags", () => {
|
||||
const result = sanitizeHtml("<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table>");
|
||||
expect(result).toContain("<table>");
|
||||
expect(result).toContain("<thead>");
|
||||
expect(result).toContain("<tbody>");
|
||||
});
|
||||
|
||||
it("preserves list tags", () => {
|
||||
const result = sanitizeHtml("<ul><li>one</li><li>two</li></ul>");
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>");
|
||||
});
|
||||
|
||||
it("preserves headings", () => {
|
||||
const result = sanitizeHtml("<h1>Title</h1><h2>Sub</h2>");
|
||||
expect(result).toContain("<h1>");
|
||||
expect(result).toContain("<h2>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCss", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeCss("")).toBe("");
|
||||
});
|
||||
|
||||
it("strips CSS comments", () => {
|
||||
const result = sanitizeCss("/* malicious */ body { color: red; }");
|
||||
expect(result).not.toContain("/*");
|
||||
expect(result).not.toContain("malicious");
|
||||
});
|
||||
|
||||
it("decodes hex CSS escapes before sanitizing", () => {
|
||||
// \\6a\\61... encodes "javascript" — the decoder should expose this and the javascript: pattern then strips
|
||||
const css = "a { background: \\6a avascript:alert(1) }";
|
||||
const result = sanitizeCss(css);
|
||||
expect(result.toLowerCase()).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips javascript: protocol", () => {
|
||||
const result = sanitizeCss("a { background: javascript:alert(1) }");
|
||||
expect(result.toLowerCase()).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips expression() syntax", () => {
|
||||
const result = sanitizeCss("a { width: expression(alert(1)) }");
|
||||
expect(result.toLowerCase()).not.toContain("expression(");
|
||||
});
|
||||
|
||||
it("strips behavior: declarations", () => {
|
||||
const result = sanitizeCss("a { behavior: url(#default#VML); }");
|
||||
expect(result.toLowerCase()).not.toContain("behavior:");
|
||||
});
|
||||
|
||||
it("strips -moz-binding declarations", () => {
|
||||
const result = sanitizeCss("a { -moz-binding: url(evil.xml#xss); }");
|
||||
expect(result.toLowerCase()).not.toContain("-moz-binding");
|
||||
});
|
||||
|
||||
it("strips @import directives", () => {
|
||||
const result = sanitizeCss("@import url('evil.css'); body { color: red; }");
|
||||
expect(result).not.toContain("@import");
|
||||
});
|
||||
|
||||
it("strips @font-face blocks", () => {
|
||||
const result = sanitizeCss("@font-face { font-family: x; src: url(evil); } body {}");
|
||||
expect(result).not.toContain("@font-face");
|
||||
});
|
||||
|
||||
it("strips url() values", () => {
|
||||
const result = sanitizeCss("a { background: url(evil.png); }");
|
||||
expect(result).not.toContain("url(");
|
||||
});
|
||||
|
||||
it("strips standalone src: declarations", () => {
|
||||
const result = sanitizeCss("src: url(x.woff);");
|
||||
expect(result).not.toContain("src:");
|
||||
});
|
||||
|
||||
it("preserves benign CSS", () => {
|
||||
const result = sanitizeCss("body { color: red; font-size: 14px; }");
|
||||
expect(result).toContain("color: red");
|
||||
expect(result).toContain("font-size: 14px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isObject", () => {
|
||||
it("returns true for plain objects", () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for arrays", () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
expect(isObject([1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isObject(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for primitives", () => {
|
||||
expect(isObject(0)).toBe(false);
|
||||
expect(isObject("string")).toBe(false);
|
||||
expect(isObject(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for class instances (objects)", () => {
|
||||
expect(isObject(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
it("narrows the type correctly", () => {
|
||||
const value: unknown = { foo: "bar" };
|
||||
if (isObject(value)) {
|
||||
// should compile-check: value has Record<string, unknown>
|
||||
expect(typeof value.foo).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateId, generateRandomName, getInitials, slugify, stripHtml, toUsername } from "./string";
|
||||
|
||||
describe("generateId", () => {
|
||||
it("returns a UUIDv7 string", () => {
|
||||
const id = generateId();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("returns unique values across invocations", () => {
|
||||
const ids = new Set(Array.from({ length: 50 }, () => generateId()));
|
||||
expect(ids.size).toBe(50);
|
||||
});
|
||||
|
||||
it("returns ids that sort lexicographically by creation time", async () => {
|
||||
const first = generateId();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const second = generateId();
|
||||
expect(second.localeCompare(first)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases and replaces spaces with hyphens", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("preserves camelCase due to decamelize: false", () => {
|
||||
expect(slugify("HelloWorld")).toBe("helloworld");
|
||||
});
|
||||
|
||||
it("strips diacritics and accents", () => {
|
||||
expect(slugify("Café")).toBe("cafe");
|
||||
expect(slugify("naïve résumé")).toBe("naive-resume");
|
||||
});
|
||||
|
||||
it("removes punctuation", () => {
|
||||
expect(slugify("Hello, World!")).toBe("hello-world");
|
||||
expect(slugify("a/b\\c.d")).toBe("a-b-c-d");
|
||||
});
|
||||
|
||||
it("collapses repeated separators", () => {
|
||||
expect(slugify("a b---c")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles unicode emojis by stripping them", () => {
|
||||
expect(slugify("Hello 🌍 World")).toBe("hello-world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInitials", () => {
|
||||
it("returns first letters of two-word names uppercased", () => {
|
||||
expect(getInitials("John Doe")).toBe("JD");
|
||||
});
|
||||
|
||||
it("uses only first two parts for longer names", () => {
|
||||
expect(getInitials("John Michael Doe")).toBe("JM");
|
||||
});
|
||||
|
||||
it("returns single uppercase initial for single word", () => {
|
||||
expect(getInitials("John")).toBe("J");
|
||||
});
|
||||
|
||||
it("uppercases lowercase input", () => {
|
||||
expect(getInitials("jane smith")).toBe("JS");
|
||||
});
|
||||
|
||||
it("handles empty string by returning empty string", () => {
|
||||
expect(getInitials("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toUsername", () => {
|
||||
it("lowercases and trims", () => {
|
||||
expect(toUsername(" JohnDoe ")).toBe("johndoe");
|
||||
});
|
||||
|
||||
it("removes characters outside [a-z0-9._-]", () => {
|
||||
expect(toUsername("john!@#doe$%^")).toBe("johndoe");
|
||||
});
|
||||
|
||||
it("preserves dots, underscores, and hyphens", () => {
|
||||
expect(toUsername("john.doe_test-123")).toBe("john.doe_test-123");
|
||||
});
|
||||
|
||||
it("truncates to 64 characters max", () => {
|
||||
const longName = "a".repeat(100);
|
||||
expect(toUsername(longName)).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("handles unicode characters by stripping them", () => {
|
||||
expect(toUsername("Café")).toBe("caf");
|
||||
expect(toUsername("日本語")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(toUsername(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateRandomName", () => {
|
||||
it("returns a string with three capitalized words", () => {
|
||||
const name = generateRandomName();
|
||||
const words = name.split(" ");
|
||||
expect(words).toHaveLength(3);
|
||||
for (const word of words) {
|
||||
expect(word[0]).toBe(word[0]?.toUpperCase());
|
||||
}
|
||||
});
|
||||
|
||||
it("produces varying values", () => {
|
||||
const names = new Set(Array.from({ length: 20 }, () => generateRandomName()));
|
||||
// Allow rare collisions but expect variety from a 20-sample pool
|
||||
expect(names.size).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHtml", () => {
|
||||
it("removes simple tags and trims", () => {
|
||||
expect(stripHtml("<p>Hello</p>")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("removes nested tags and preserves text", () => {
|
||||
expect(stripHtml("<div><span>Hello</span> <strong>World</strong></div>")).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined input", () => {
|
||||
expect(stripHtml(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(stripHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles self-closing tags", () => {
|
||||
expect(stripHtml("Line one<br/>Line two")).toBe("Line oneLine two");
|
||||
});
|
||||
|
||||
it("strips attributes within tags", () => {
|
||||
expect(stripHtml('<a href="https://example.com" title="link">Click</a>')).toBe("Click");
|
||||
});
|
||||
|
||||
it("preserves text containing angle brackets when not malformed tags", () => {
|
||||
// Greater-than after a closed tag stays
|
||||
expect(stripHtml("<p>5 > 3</p>")).toBe("5 > 3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cn } from "./style";
|
||||
|
||||
describe("cn", () => {
|
||||
it("joins simple class strings", () => {
|
||||
expect(cn("a", "b", "c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("filters out falsy values", () => {
|
||||
expect(cn("a", null, undefined, false, "b", 0)).toBe("a b");
|
||||
});
|
||||
|
||||
it("conditionally applies classes via object syntax", () => {
|
||||
expect(cn({ a: true, b: false, c: true })).toBe("a c");
|
||||
});
|
||||
|
||||
it("flattens arrays of inputs", () => {
|
||||
expect(cn(["a", "b"], ["c"])).toBe("a b c");
|
||||
});
|
||||
|
||||
it("merges conflicting tailwind classes (later wins)", () => {
|
||||
expect(cn("p-4", "p-2")).toBe("p-2");
|
||||
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
|
||||
});
|
||||
|
||||
it("preserves non-conflicting tailwind classes", () => {
|
||||
expect(cn("text-red-500", "bg-white")).toContain("text-red-500");
|
||||
expect(cn("text-red-500", "bg-white")).toContain("bg-white");
|
||||
});
|
||||
|
||||
it("handles responsive variants distinctly from base classes", () => {
|
||||
expect(cn("p-4", "md:p-2")).toBe("p-4 md:p-2");
|
||||
});
|
||||
|
||||
it("returns empty string for no input", () => {
|
||||
expect(cn()).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when all inputs are falsy", () => {
|
||||
expect(cn(null, undefined, false)).toBe("");
|
||||
});
|
||||
|
||||
it("merges twMerge group conflicts (e.g., flex-row vs flex-col)", () => {
|
||||
expect(cn("flex-row", "flex-col")).toBe("flex-col");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isAllowedExternalUrl,
|
||||
isAllowedOAuthRedirectUri,
|
||||
isPrivateOrLoopbackHost,
|
||||
parseAllowedHostList,
|
||||
parseUrl,
|
||||
} from "./url-security.node";
|
||||
|
||||
describe("isPrivateOrLoopbackHost", () => {
|
||||
describe("loopback hostnames", () => {
|
||||
it("matches localhost", () => {
|
||||
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches LOCALHOST (case-insensitive)", () => {
|
||||
expect(isPrivateOrLoopbackHost("LOCALHOST")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches subdomains of localhost", () => {
|
||||
expect(isPrivateOrLoopbackHost("api.localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches IPv6 loopback ::1", () => {
|
||||
expect(isPrivateOrLoopbackHost("::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches bracketed IPv6 loopback [::1]", () => {
|
||||
expect(isPrivateOrLoopbackHost("[::1]")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private IPv4 ranges", () => {
|
||||
it("matches 10.0.0.0/8", () => {
|
||||
expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("10.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 127.0.0.0/8 (loopback)", () => {
|
||||
expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 169.254.0.0/16 (link-local)", () => {
|
||||
expect(isPrivateOrLoopbackHost("169.254.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 172.16.0.0/12", () => {
|
||||
expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("172.31.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match outside 172.16-31", () => {
|
||||
expect(isPrivateOrLoopbackHost("172.15.0.1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("172.32.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches 192.168.0.0/16", () => {
|
||||
expect(isPrivateOrLoopbackHost("192.168.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("192.168.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 0.0.0.0/8", () => {
|
||||
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match public IPs", () => {
|
||||
expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("1.1.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private IPv6 ranges", () => {
|
||||
it("matches unique-local fc00::/7", () => {
|
||||
expect(isPrivateOrLoopbackHost("fc00::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("fd12::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches link-local fe80::/10", () => {
|
||||
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match global IPv6", () => {
|
||||
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-IP, non-loopback hostnames", () => {
|
||||
it("returns false for public domain", () => {
|
||||
expect(isPrivateOrLoopbackHost("example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for arbitrary string", () => {
|
||||
expect(isPrivateOrLoopbackHost("not-a-real-host")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrl", () => {
|
||||
it("returns URL object for valid URL", () => {
|
||||
const url = parseUrl("https://example.com/path");
|
||||
expect(url).not.toBeNull();
|
||||
expect(url?.hostname).toBe("example.com");
|
||||
});
|
||||
|
||||
it("returns null for invalid URL", () => {
|
||||
expect(parseUrl("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for relative URL", () => {
|
||||
expect(parseUrl("/path/only")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAllowedHostList", () => {
|
||||
it("returns empty Set for undefined", () => {
|
||||
expect(parseAllowedHostList()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty Set for empty string", () => {
|
||||
expect(parseAllowedHostList("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("parses single host", () => {
|
||||
expect(parseAllowedHostList("example.com")).toEqual(new Set(["example.com"]));
|
||||
});
|
||||
|
||||
it("parses comma-separated list", () => {
|
||||
expect(parseAllowedHostList("example.com,api.example.com")).toEqual(new Set(["example.com", "api.example.com"]));
|
||||
});
|
||||
|
||||
it("trims whitespace around entries", () => {
|
||||
expect(parseAllowedHostList(" a.com , b.com ")).toEqual(new Set(["a.com", "b.com"]));
|
||||
});
|
||||
|
||||
it("lowercases entries", () => {
|
||||
expect(parseAllowedHostList("Example.COM")).toEqual(new Set(["example.com"]));
|
||||
});
|
||||
|
||||
it("filters out empty entries", () => {
|
||||
expect(parseAllowedHostList("a.com,,b.com,")).toEqual(new Set(["a.com", "b.com"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedExternalUrl", () => {
|
||||
const allowed = new Set(["api.example.com", "https://full.example.com"]);
|
||||
|
||||
it("returns false for malformed URLs", () => {
|
||||
expect(isAllowedExternalUrl("not a url", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-https protocols", () => {
|
||||
expect(isAllowedExternalUrl("http://api.example.com", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("ftp://api.example.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when URL contains credentials", () => {
|
||||
expect(isAllowedExternalUrl("https://user:pass@api.example.com", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://user@api.example.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for private/loopback hosts", () => {
|
||||
expect(isAllowedExternalUrl("https://localhost", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://127.0.0.1", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://192.168.1.1", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches by hostname", () => {
|
||||
expect(isAllowedExternalUrl("https://api.example.com/path", allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by full origin", () => {
|
||||
expect(isAllowedExternalUrl("https://full.example.com/x", allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-listed hostnames", () => {
|
||||
expect(isAllowedExternalUrl("https://evil.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches case-insensitively in hostname", () => {
|
||||
expect(isAllowedExternalUrl("https://API.EXAMPLE.COM", allowed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedOAuthRedirectUri", () => {
|
||||
const trustedOrigins = ["https://app.example.com"];
|
||||
const allowed = new Set(["api.example.com"]);
|
||||
|
||||
it("returns false for malformed URI", () => {
|
||||
expect(isAllowedOAuthRedirectUri("nope", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when credentials present", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://u:p@app.example.com", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when fragment present", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb#x", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows http for loopback (localhost)", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://localhost:3000/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows http for 127.0.0.1", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://127.0.0.1/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows http for IPv6 loopback", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://[::1]/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http for non-loopback hosts", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://example.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-https/non-http protocols", () => {
|
||||
expect(isAllowedOAuthRedirectUri("ftp://example.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects https with private/loopback host", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://192.168.1.1/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches trusted origins", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against allowed origins set", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://api.example.com/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-allowed public host", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://evil.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createUrl } from "./url";
|
||||
|
||||
describe("createUrl", () => {
|
||||
it("returns empty url and label when no url provided", () => {
|
||||
expect(createUrl()).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("returns empty pair when url is empty string (falsy)", () => {
|
||||
expect(createUrl("")).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("uses url as label when no label provided", () => {
|
||||
expect(createUrl("https://example.com")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves provided label", () => {
|
||||
expect(createUrl("https://example.com", "Example")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to url when label is empty string", () => {
|
||||
expect(createUrl("https://example.com", "")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not validate the url format (caller's responsibility)", () => {
|
||||
expect(createUrl("not-a-url", "Label")).toEqual({
|
||||
url: "not-a-url",
|
||||
label: "Label",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user