Files
bookhoard/web/src/markdown.test.ts
T
John O'Keefe e5315f1b94 test(web): vitest suites for markdown, filters, password rules, api, storage, toast
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.

- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
  DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
  round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
  smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
  full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
  Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
  sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
  'warning' entry regression) and render behavior.

Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.

jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.

Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
2026-10-21 15:34:29 -04:00

119 lines
4.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { htmlToMarkdown, renderNoteHtml, stripNote } from "./markdown";
// The sanitizer pipeline is the notes feature's XSS defense: markdown-it
// with html disabled, then DOMPurify with a restrictive allow-list. These
// tests pin the security-relevant behavior — loosening any of them should
// require a deliberate decision, never an accident.
describe("renderNoteHtml", () => {
it("renders basic markdown to html", () => {
const out = renderNoteHtml("**bold** and _ital_");
expect(out).toContain("<strong>bold</strong>");
expect(out).toContain("<em>ital</em>");
});
it("returns empty string for empty source", () => {
expect(renderNoteHtml("")).toBe("");
});
it("strips raw script tags (parser-level html disabled)", () => {
const out = renderNoteHtml("<script>alert(1)</script>hello");
expect(out).not.toContain("<script");
expect(out).toContain("hello");
});
it("removes img/input/style/form tags", () => {
const out = renderNoteHtml(
'![x](y.png)\n\n<form action="/steal"></form>\n\n<style>.x{}</style>\n\n<input type="text">',
);
expect(out).not.toContain("<img");
expect(out).not.toContain("<form");
expect(out).not.toContain("<style");
expect(out).not.toContain("<input");
});
it("escapes raw html to inert literal text (html disabled at the parser)", () => {
// html:false makes markdown-it ESCAPE raw html — the output string
// contains the tags as visible text, which is inert wherever the
// compact rows render it.
const out = renderNoteHtml('<span onerror="alert(1)">text</span>');
expect(out).not.toContain("<span");
expect(out).toContain("onerror");
expect(out).toContain("text");
});
it("neutralizes javascript: links at the markdown layer (no anchor emitted)", () => {
// markdown-it's validateLink rejects the URL entirely — the raw syntax
// stays as inert literal text and no <a> element ever reaches the
// sanitizer (which would then scheme-check it a second time).
const out = renderNoteHtml("[click](javascript:alert(1))");
expect(out).not.toContain("<a");
expect(out).toContain("[click](javascript:alert(1))");
});
it("removes markdown-syntax images (FORBID_TAGS)", () => {
const out = renderNoteHtml("![x](y.png)");
expect(out).not.toContain("<img");
});
it("keeps safe https links", () => {
const out = renderNoteHtml("[site](https://example.com)");
expect(out).toContain('href="https://example.com"');
});
it("renders footnotes", () => {
const out = renderNoteHtml("note[^1]\n\n[^1]: the footnote");
expect(out).toContain("footnote-ref");
expect(out).toContain("the footnote");
});
});
describe("stripNote", () => {
it("extracts plain text from markdown", () => {
expect(stripNote("**bold** plain")).toBe("bold plain");
});
it("collapses whitespace and trims", () => {
expect(stripNote("a\n\n b ")).toBe("a b");
});
it("reduces raw html to literal text (safe: html disabled, so tags never become elements)", () => {
// markdown-it escapes the tags and DOMPurify leaves escaped entities
// alone — the result is inert visible text, not executable markup.
expect(stripNote("<script>x</script>safe")).toBe("<script>x</script>safe");
});
it("returns empty string for empty source", () => {
expect(stripNote("")).toBe("");
});
});
describe("htmlToMarkdown", () => {
it("converts emphasis", () => {
expect(htmlToMarkdown("<strong>b</strong>")).toBe("**b**");
});
it("converts atx headings", () => {
expect(htmlToMarkdown("<h2>Title</h2>")).toBe("## Title");
});
it("blanks images entirely", () => {
expect(htmlToMarkdown('<p>a<img src="x.png">b</p>')).toBe("ab");
});
it("converts tables with heading rows to pipe syntax", () => {
const md = htmlToMarkdown(
"<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
);
expect(md).toContain("| A | B |");
expect(md).toContain("| 1 | 2 |");
});
it("converts headingless tables to blank-header pipes (never raw html)", () => {
const md = htmlToMarkdown("<table><tr><td>x</td><td>y</td></tr></table>");
expect(md).toContain("| x | y |");
expect(md).not.toContain("<table");
});
});