| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import "./dom"; |
| 4 | // The sanitizer (DOM-bound: DOMParser) and the HTML live preview through a real document-preset |
| 5 | // view. The security assertions here are the contract: nothing outside the allowlist may ever |
| 6 | // reach the rendered DOM. |
| 7 | import { EditorSelection } from "@codemirror/state"; |
| 8 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 9 | |
| 10 | import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts"; |
| 11 | import { sanitizeHtml } from "../src/html.ts"; |
| 12 | |
| 13 | const IMG = "data:image/svg+xml;base64,PHN2Zy8+"; |
| 14 | |
| 15 | describe("sanitizeHtml", () => { |
| 16 | it("drops script/style/iframe with their content", () => { |
| 17 | expect(sanitizeHtml("<script>alert(1)</script>")).toBeNull(); |
| 18 | expect(sanitizeHtml("<style>*{}</style><iframe src='x'></iframe>")).toBeNull(); |
| 19 | }); |
| 20 | |
| 21 | it("strips event handlers and unknown attributes", () => { |
| 22 | const clean = sanitizeHtml(`<img src="${IMG}" onerror="alert(1)" data-x="1">`)!; |
| 23 | const img = clean.querySelector("img")!; |
| 24 | expect(img.getAttribute("onerror")).toBeNull(); |
| 25 | expect(img.getAttribute("data-x")).toBeNull(); |
| 26 | expect(img.getAttribute("src")).toBe(IMG); |
| 27 | }); |
| 28 | |
| 29 | it("rejects javascript: links and keeps https ones hardened", () => { |
| 30 | const clean = sanitizeHtml('<a href="javascript:alert(1)">x</a><a href="https://ok">y</a>')!; |
| 31 | const links = clean.querySelectorAll("a"); |
| 32 | expect(links[0]?.getAttribute("href")).toBeNull(); |
| 33 | expect(links[1]?.getAttribute("href")).toBe("https://ok"); |
| 34 | expect(links[1]?.getAttribute("target")).toBe("_blank"); |
| 35 | expect(links[1]?.getAttribute("rel")).toBe("noopener noreferrer"); |
| 36 | }); |
| 37 | |
| 38 | it("rejects non-image data: urls", () => { |
| 39 | const clean = sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdC8+">')!; |
| 40 | expect(clean.querySelector("img")?.getAttribute("src")).toBeNull(); |
| 41 | }); |
| 42 | |
| 43 | it("unwraps harmless unknown tags, keeping their children", () => { |
| 44 | const clean = sanitizeHtml("<blink>keep <b>me</b></blink>")!; |
| 45 | expect(clean.querySelector("blink")).toBeNull(); |
| 46 | expect(clean.textContent).toBe("keep me"); |
| 47 | expect(clean.querySelector("b")?.textContent).toBe("me"); |
| 48 | }); |
| 49 | |
| 50 | it("routes relative srcs through resolveUrl and filters srcset entries", () => { |
| 51 | const clean = sanitizeHtml( |
| 52 | `<img src="./logo.png" srcset="./logo.png 1x, javascript:x 2x, ${IMG} 3x">`, |
| 53 | { resolveUrl: (url) => `resolved:${url}` }, |
| 54 | )!; |
| 55 | const img = clean.querySelector("img")!; |
| 56 | expect(img.getAttribute("src")).toBe("resolved:./logo.png"); |
| 57 | expect(img.getAttribute("srcset")).toBe(`resolved:./logo.png 1x, ${IMG} 3x`); |
| 58 | }); |
| 59 | |
| 60 | it("keeps picture/source media queries", () => { |
| 61 | const clean = sanitizeHtml( |
| 62 | `<picture><source media="(prefers-color-scheme: dark)" srcset="${IMG}"><img src="${IMG}"></picture>`, |
| 63 | )!; |
| 64 | expect(clean.querySelector("source")?.getAttribute("media")).toBe( |
| 65 | "(prefers-color-scheme: dark)", |
| 66 | ); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | let destroy: (() => void) | undefined; |
| 71 | afterEach(() => { |
| 72 | destroy?.(); |
| 73 | destroy = undefined; |
| 74 | }); |
| 75 | |
| 76 | function mount(doc: string, head = 0): MarkdownEditor { |
| 77 | const editor = createMarkdownEditor({ preset: "document", doc }); |
| 78 | document.body.append(editor.dom); |
| 79 | editor.view.contentDOM.dispatchEvent(new FocusEvent("focus")); |
| 80 | editor.view.dispatch({ selection: EditorSelection.single(head) }); |
| 81 | destroy = () => { |
| 82 | editor.destroy(); |
| 83 | editor.dom.remove(); |
| 84 | }; |
| 85 | return editor; |
| 86 | } |
| 87 | |
| 88 | describe("html blocks in the document preset", () => { |
| 89 | it("renders a balanced block sanitized, away from the caret", () => { |
| 90 | const doc = `<p align="center"><img src="${IMG}" alt="logo" width="120"></p>\n\nafter`; |
| 91 | const editor = mount(doc, doc.length); |
| 92 | const html = editor.view.dom.querySelector(".cm-lp-html"); |
| 93 | expect(html?.querySelector("img")?.getAttribute("width")).toBe("120"); |
| 94 | expect(editor.view.dom.textContent).not.toContain("<p"); |
| 95 | }); |
| 96 | |
| 97 | it("shows raw html source when the caret enters the block", () => { |
| 98 | const doc = `<p align="center"><img src="${IMG}"></p>\n\nafter`; |
| 99 | const editor = mount(doc, 3); |
| 100 | expect(editor.view.dom.querySelector(".cm-lp-html")).toBeNull(); |
| 101 | expect(editor.view.dom.querySelectorAll(".cm-lp-html-src").length).toBe(1); |
| 102 | expect(editor.view.dom.textContent).toContain('<p align="center">'); |
| 103 | }); |
| 104 | |
| 105 | it("never renders script content, even though the block previews", () => { |
| 106 | const doc = "<script>alert(1)</script>\n\nafter"; |
| 107 | const editor = mount(doc, doc.length); |
| 108 | expect(editor.view.dom.textContent).not.toContain("alert"); |
| 109 | expect(editor.view.dom.querySelector("script")).toBeNull(); |
| 110 | }); |
| 111 | |
| 112 | it("absorbs a split centering wrapper: tags hidden, content centered", () => { |
| 113 | const doc = `<div align="center">\n\n# Title\n\nsome badges\n\n</div>\n\nafter`; |
| 114 | const editor = mount(doc, doc.length); |
| 115 | expect(editor.view.dom.textContent).not.toContain("<div"); |
| 116 | expect(editor.view.dom.textContent).not.toContain("</div>"); |
| 117 | const centered = editor.view.dom.querySelectorAll(".cm-lp-center"); |
| 118 | expect(centered.length).toBeGreaterThanOrEqual(2); |
| 119 | expect(editor.view.dom.querySelector(".cm-lp-h1.cm-lp-center")).not.toBeNull(); |
| 120 | }); |
| 121 | |
| 122 | it("reveals a wrapper tag as source when the caret reaches its line", () => { |
| 123 | const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`; |
| 124 | const editor = mount(doc, 4); |
| 125 | expect(editor.view.dom.textContent).toContain('<div align="center">'); |
| 126 | expect(editor.view.dom.querySelector(".cm-lp-html-src")).not.toBeNull(); |
| 127 | }); |
| 128 | |
| 129 | it("leaves an unmatched wrapper open visible as source", () => { |
| 130 | const doc = `<div align="center">\n\ncontent with no close\n\nafter`; |
| 131 | const editor = mount(doc, doc.length); |
| 132 | expect(editor.view.dom.textContent).toContain('<div align="center">'); |
| 133 | }); |
| 134 | |
| 135 | it("collapses comment blocks to a placeholder that expands on caret entry", () => { |
| 136 | const doc = "<!-- secret plan -->\n\nafter"; |
| 137 | const away = mount(doc, doc.length); |
| 138 | expect(away.view.dom.textContent).not.toContain("secret plan"); |
| 139 | expect(away.view.dom.querySelector(".cm-lp-comment")?.textContent).toBe("<!-- ⋯ -->"); |
| 140 | destroy?.(); |
| 141 | destroy = undefined; |
| 142 | const inside = mount(doc, 4); |
| 143 | expect(inside.view.dom.textContent).toContain("secret plan"); |
| 144 | expect(inside.view.dom.querySelector(".cm-lp-comment")).toBeNull(); |
| 145 | }); |
| 146 | |
| 147 | it("reveals both halves of an absorbed wrapper together, with token highlighting", () => { |
| 148 | const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`; |
| 149 | const editor = mount(doc, 4); // caret inside the opening <div> |
| 150 | expect(editor.view.dom.textContent).toContain('<div align="center">'); |
| 151 | expect(editor.view.dom.textContent).toContain("</div>"); |
| 152 | const tags = [...editor.view.dom.querySelectorAll(".cm-html-tok-tag")]; |
| 153 | expect(tags.map((tag) => tag.textContent)).toEqual(["div", "div"]); |
| 154 | expect(editor.view.dom.querySelector(".cm-html-tok-attr")?.textContent).toBe("align"); |
| 155 | expect(editor.view.dom.querySelector(".cm-html-tok-value")?.textContent).toBe('"center"'); |
| 156 | }); |
| 157 | |
| 158 | it("reveals the opener from the closer too", () => { |
| 159 | const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`; |
| 160 | const editor = mount(doc, doc.indexOf("</div>") + 2); |
| 161 | expect(editor.view.dom.textContent).toContain('<div align="center">'); |
| 162 | expect(editor.view.dom.textContent).toContain("</div>"); |
| 163 | }); |
| 164 | }); |
| 165 | |
| 166 | describe("inline html in the document preset", () => { |
| 167 | it("styles paired tags and hides them away from the caret", () => { |
| 168 | const doc = "press <kbd>Cmd</kbd> and <sub>low</sub>\n\nafter"; |
| 169 | const editor = mount(doc, doc.length); |
| 170 | expect(editor.view.dom.querySelector(".cm-html-kbd")?.textContent).toBe("Cmd"); |
| 171 | expect(editor.view.dom.querySelector(".cm-html-sub")?.textContent).toBe("low"); |
| 172 | expect(editor.view.dom.textContent).not.toContain("<kbd>"); |
| 173 | }); |
| 174 | |
| 175 | it("reveals paired tags as muted source when the caret touches the pair", () => { |
| 176 | const editor = mount("press <kbd>Cmd</kbd> now\n\nafter", 8); |
| 177 | expect(editor.view.dom.textContent).toContain("<kbd>"); |
| 178 | expect(editor.view.dom.querySelectorAll(".cm-lp-html-inline").length).toBe(2); |
| 179 | }); |
| 180 | |
| 181 | it("renders <br> as a line break widget", () => { |
| 182 | const doc = "a<br>b\n\nafter"; |
| 183 | const editor = mount(doc, doc.length); |
| 184 | expect(editor.view.dom.querySelector(".cm-lp-br br")).not.toBeNull(); |
| 185 | expect(editor.view.dom.textContent).not.toContain("<br>"); |
| 186 | }); |
| 187 | |
| 188 | it("renders an inline <img> and a badge-style <a><img></a> as sanitized widgets", () => { |
| 189 | const doc = `logo <img src="${IMG}"> and <a href="https://x"><img src="${IMG}"></a>\n\nafter`; |
| 190 | const editor = mount(doc, doc.length); |
| 191 | const widgets = editor.view.dom.querySelectorAll(".cm-lp-html"); |
| 192 | expect(widgets.length).toBe(2); |
| 193 | expect(widgets[1]?.querySelector("a[target='_blank'] img")).not.toBeNull(); |
| 194 | }); |
| 195 | |
| 196 | it("leaves unknown or unpaired tags visible as muted source", () => { |
| 197 | const doc = "a <blink>x</blink> and <b>unclosed\n\nafter"; |
| 198 | const editor = mount(doc, doc.length); |
| 199 | expect(editor.view.dom.textContent).toContain("<blink>"); |
| 200 | expect(editor.view.dom.textContent).toContain("<b>"); |
| 201 | }); |
| 202 | |
| 203 | it("keeps the composer preset html-free (raw text, no widgets)", () => { |
| 204 | const composer = createMarkdownEditor({ doc: `x <img src="${IMG}"> <b>hi</b>` }); |
| 205 | document.body.append(composer.dom); |
| 206 | expect(composer.dom.querySelector(".cm-lp-html")).toBeNull(); |
| 207 | expect(composer.dom.textContent).toContain("<b>hi</b>"); |
| 208 | composer.destroy(); |
| 209 | composer.dom.remove(); |
| 210 | }); |
| 211 | }); |