import { Linter } from "eslint"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { noRawHtmlSinks } from "./eslint.js"; import { parseProviderHtml } from "./html.js"; import { parsePlainText } from "./plain.js"; import { RichText } from "./react.js"; import { isProxiedMediaUrl, parseSafeHttpUrl, toProxiedMediaUrl } from "./url.js"; import { coerceRichTextDocument, parseLinkPreview } from "./validate.js"; describe("Mastodon HTML", () => { const status = '

@alice ' + 'look :blobcat:

' + '

read ' + 'news.example/articles/2026/v
bye

'; it("maps mentions, hashtags, links, emoji and breaks to the AST", () => { const doc = parseProviderHtml(status, { emojis: [{ shortcode: "blobcat", url: "https://cdn.example/blobcat.png" }] }); expect(doc).toEqual({ type: "doc", version: 1, truncated: false, children: [ { type: "paragraph", children: [ { type: "mention", handle: "alice@social.example", href: "https://social.example/@alice" }, { type: "text", text: " look " }, { type: "emoji", shortcode: "blobcat", src: "/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fblobcat.png" }, { type: "text", text: " " }, { type: "hashtag", tag: "fediverse", href: "https://social.example/tags/fediverse" }, ], }, { type: "paragraph", children: [ { type: "text", text: "read " }, { type: "link", href: "https://news.example/articles/2026/very-long-path", host: "news.example", children: [{ type: "text", text: "news.example/articles/2026/v…" }], }, { type: "linebreak" }, { type: "text", text: "bye" }, ], }, ], }); }); it("renders the expected markup", () => { const html = renderToStaticMarkup(); expect(html).toContain('@alice@social.example'); expect(html).toContain(":blobcat:"); expect(html).not.toContain("sc-link-host"); }); }); describe("plain text", () => { it("linkifies http(s) URLs, mentions, hashtags and newlines", () => { const doc = parsePlainText("hi @bob@x.example see https://a.example/p?q=1. #tag\nline2\n\npara2"); expect(doc.children).toHaveLength(2); expect(doc.children[0]!.children).toEqual([ { type: "text", text: "hi " }, { type: "mention", handle: "bob@x.example" }, { type: "text", text: " see " }, { type: "link", href: "https://a.example/p?q=1", host: "a.example", children: [{ type: "text", text: "a.example/p?q=1" }] }, { type: "text", text: ". " }, { type: "hashtag", tag: "tag" }, { type: "linebreak" }, { type: "text", text: "line2" }, ]); }); it("ignores non-string input", () => { expect(parsePlainText(42).children).toEqual([]); }); }); describe("URL policy", () => { it.each([ "javascript:alert(1)", " javascript:alert(1)", "JAVASCRIPT:alert(1)", "java\tscript:alert(1)", "data:text/html,x", "vbscript:x", "file:///etc/passwd", "//evil.example", "/relative", "https:evil.example", "https:/evil.example", "https://user@evil.example/", "https://:pw@evil.example/", "http://evil.example/\u0000", "https://evil.example/‮", "ftp://evil.example/", "https://" + "a".repeat(3_000) + ".example/", "", ])("rejects %j", (raw) => { expect(parseSafeHttpUrl(raw)).toBeNull(); }); it("accepts http(s) and normalises", () => { expect(parseSafeHttpUrl("HTTPS://Ok.Example/a b".replace(" ", "%20"))).toEqual({ href: "https://ok.example/a%20b", host: "ok.example" }); }); it("media proxy URLs are canonical and same-origin", () => { const src = toProxiedMediaUrl("https://cdn.example/a.png"); expect(src).toBe("/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fa.png"); expect(isProxiedMediaUrl(src)).toBe(true); expect(isProxiedMediaUrl("https://cdn.example/a.png")).toBe(false); expect(isProxiedMediaUrl("/api/media-proxy?url=https://cdn.example/a.png")).toBe(false); expect(isProxiedMediaUrl("/api/media-proxy?url=javascript%3Aalert(1)")).toBe(false); expect(toProxiedMediaUrl("data:image/png;base64,AAAA")).toBeNull(); expect(() => toProxiedMediaUrl("https://cdn.example/a.png", { proxyPath: "//evil.example/p" })).toThrow(); expect(() => toProxiedMediaUrl("https://cdn.example/a.png", { proxyPath: "https://evil.example/p" })).toThrow(); }); it("supports a custom proxy path end to end", () => { const mediaProxy = { proxyPath: "/media/p" }; const doc = parseProviderHtml("hi :e:", { mediaProxy, emojis: [{ shortcode: "e", url: "https://cdn.example/e.png" }] }); const html = renderToStaticMarkup(); expect(html).toContain('src="/media/p?url=https%3A%2F%2Fcdn.example%2Fe.png"'); // Same doc rendered against a different proxy: emoji degrades to text. expect(renderToStaticMarkup()).not.toContain(" { it("round-trips a valid document unchanged", () => { const doc = parseProviderHtml('

a ok.example @x #y

'); expect(coerceRichTextDocument(JSON.parse(JSON.stringify(doc)))).toEqual(doc); }); it("re-applies node limits", () => { const forged = { type: "doc", children: [{ type: "paragraph", children: Array.from({ length: 10_000 }, (_, i) => (i % 2 ? { type: "linebreak" } : { type: "mention", handle: "a" })) }] }; const doc = coerceRichTextDocument(forged); expect(doc.truncated).toBe(true); expect(doc.children[0]!.children.length).toBeLessThanOrEqual(2_000); }); it("bounds total text from forged nodes", () => { const forged = { type: "doc", children: [{ type: "paragraph", children: [{ type: "text", text: "x".repeat(5_000_000) }] }] }; const doc = coerceRichTextDocument(forged); expect(doc.truncated).toBe(true); expect(JSON.stringify(doc).length).toBeLessThan(25_000); }); }); describe("parseLinkPreview", () => { it("builds a preview with proxied image and plain-text fields", () => { expect(parseLinkPreview({ url: "https://ok.example/a", title: "A & B", siteName: "Ok", image: "https://cdn.example/i.png" })).toEqual({ type: "linkPreview", href: "https://ok.example/a", host: "ok.example", title: "A & B", siteName: "Ok", image: "/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fi.png", }); }); }); describe("eslint raw-HTML sink rule", () => { const linter = new Linter({ configType: "flat" }); const lint = (code: string) => linter.verify(code, [ { languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } }, rules: noRawHtmlSinks.rules, }, ]); it.each([ "const x =
;", "React.createElement('div', { dangerouslySetInnerHTML: { __html: s } });", "el.innerHTML = s;", "el.outerHTML = s;", "el.insertAdjacentHTML('beforeend', s);", "document.write(s);", "new Function(s)();", "eval(s);", "new DOMParser().parseFromString(s, 'text/html');", ])("flags %s", (code) => { expect(lint(code).map((m) => m.ruleId)).toContain("no-restricted-syntax"); }); it("allows text rendering", () => { expect(lint("const x =
{post.text}
; el.textContent = s;")).toEqual([]); }); });