Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 70 additions & 64 deletions test/sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,103 +2,109 @@ import { describe, it, expect } from "vitest";
import { stripHtml, validateTextInput } from "../src/lib/sanitize";

describe("stripHtml", () => {
it("removes HTML tags", () => {
expect(stripHtml("<b>Hello</b>")).toBe("Hello");
it("returns plain text unchanged", () => {
expect(stripHtml("hello world")).toBe("hello world");
});

it("removes nested HTML tags", () => {
expect(stripHtml("<div><p>Test</p></div>")).toBe("Test");
it("strips simple tags", () => {
expect(stripHtml("<b>bold</b>")).toBe("bold");
});

it("decodes HTML entities", () => {
expect(stripHtml("&lt;script&gt;")).toBe("<script>");
it("strips nested tags", () => {
expect(stripHtml("<div><p>text</p></div>")).toBe("text");
});

it("decodes ampersand entity", () => {
expect(stripHtml("Tom &amp; Jerry")).toBe("Tom & Jerry");
it("strips tags with attributes", () => {
expect(stripHtml('<a href="http://evil.com">link</a>')).toBe("link");
});

it("decodes quote entities", () => {
expect(stripHtml("&quot;Hello&quot;")).toBe('"Hello"');
it("decodes &amp;", () => {
expect(stripHtml("A &amp; B")).toBe("A & B");
});

it("trims whitespace", () => {
expect(stripHtml(" Hello World ")).toBe("Hello World");
it("decodes &quot;", () => {
expect(stripHtml("He said &quot;hi&quot;")).toBe('He said "hi"');
});

it("returns empty string for empty input", () => {
expect(stripHtml("")).toBe("");
it("decodes entity-encoded tags to empty (entities decoded then stripped as tags)", () => {
expect(stripHtml("&lt;script&gt;")).toBe("");
});

it("strips entity-encoded tags with content", () => {
expect(stripHtml("&lt;b&gt;bold&lt;/b&gt;")).toBe("bold");
});

it("handles newlines in tags", () => {
expect(stripHtml("line1\n<tag>\nline2")).toBe("line1\n\nline2");
});

it("normalizes and strips Unicode look-alike full-width angle brackets", () => {
expect(stripHtml("<script>alert(1)</script>")).toBe("alert(1)");
expect(stripHtml("<b>Hello</b>")).toBe("Hello");
it("trims whitespace", () => {
expect(stripHtml(" hello ")).toBe("hello");
});

it("handles empty string", () => {
expect(stripHtml("")).toBe("");
});
});

describe("validateTextInput", () => {
it("accepts valid text", () => {
expect(validateTextInput("Hello", "Name")).toEqual({
ok: true,
value: "Hello",
});
it("returns ok for valid non-empty string", () => {
const result = validateTextInput("hello world", "name");
expect(result.ok).toBe(true);
expect(result.value).toBe("hello world");
});

it("strips HTML before validation", () => {
expect(validateTextInput("<b>Hello</b>", "Name")).toEqual({
ok: true,
value: "Hello",
});
it("strips HTML from value", () => {
const result = validateTextInput("<b>hello</b>", "name");
expect(result.ok).toBe(true);
expect(result.value).toBe("hello");
});

it("rejects non-string input", () => {
expect(validateTextInput(123, "Name")).toEqual({
ok: false,
value: "",
error: "Name must be a string",
});
it("returns error for non-string", () => {
const result = validateTextInput(123, "name");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/must be a string/);
});

it("rejects empty string", () => {
expect(validateTextInput("", "Name")).toEqual({
ok: false,
value: "",
error: "Name must not be empty",
});
it("returns error for null", () => {
const result = validateTextInput(null, "name");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/must be a string/);
});

it("rejects HTML-only content", () => {
expect(validateTextInput("<div></div>", "Name")).toEqual({
ok: false,
value: "",
error: "Name must not be empty",
});
it("returns error for undefined", () => {
const result = validateTextInput(undefined, "name");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/must be a string/);
});

it("rejects text exceeding max length", () => {
const longText = "a".repeat(201);
it("returns error for empty string after HTML strip", () => {
const result = validateTextInput("<b></b>", "name");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/must not be empty/);
});

expect(validateTextInput(longText, "Name")).toEqual({
ok: false,
value: "",
error: "Name must be 200 characters or fewer",
});
it("returns error for string exceeding maxLen", () => {
const result = validateTextInput("hello world", "name", 5);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/5 characters/);
});

it("accepts text exactly at max length", () => {
const text = "a".repeat(200);
it("uses default maxLen of 200", () => {
const long = "x".repeat(201);
const result = validateTextInput(long, "name");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/200 characters/);
});

expect(validateTextInput(text, "Name")).toEqual({
ok: true,
value: text,
});
it("custom maxLen parameter", () => {
const result = validateTextInput("hello", "name", 3);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/3 characters/);
});

it("supports custom max length", () => {
expect(validateTextInput("abcdef", "Name", 5)).toEqual({
ok: false,
value: "",
error: "Name must be 5 characters or fewer",
});
it("includes field name in error message", () => {
const result = validateTextInput(42, "username");
expect(result.error).toMatch(/username/);
});
});
Loading