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.
This commit is contained in:
1 parent
86c4940426
commit
e5315f1b94
12 files changed
+685
-6
No files matched your search
@@ -0,0 +1,118 @@
|
||||
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(
|
||||
'\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("");
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user