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: #fediverse
' +
'read ' +
'https://news.example/articles/2026/very-long-path
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([]);
});
});