T-HUB/packages/safe-content/src/parse.test.tsx
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

179 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 =
'<p><span class="h-card" translate="no"><a href="https://social.example/@alice" class="u-url mention">@<span>alice</span></a></span> ' +
'look :blobcat: <a href="https://social.example/tags/fediverse" class="mention hashtag" rel="tag">#<span>fediverse</span></a></p>' +
'<p>read <a href="https://news.example/articles/2026/very-long-path" target="_blank" rel="nofollow noopener noreferrer" translate="no">' +
'<span class="invisible">https://</span><span class="ellipsis">news.example/articles/2026/v</span><span class="invisible">ery-long-path</span></a><br>bye</p>';
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(<RichText doc={parseProviderHtml(status)} />);
expect(html).toContain('<a href="https://social.example/@alice" rel="noopener noreferrer nofollow ugc" target="_blank" referrerPolicy="no-referrer" class="sc-mention">@alice@social.example</a>');
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(<RichText doc={doc} mediaProxy={mediaProxy} />);
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(<RichText doc={doc} />)).not.toContain("<img");
});
});
describe("coerceRichTextDocument", () => {
it("round-trips a valid document unchanged", () => {
const doc = parseProviderHtml('<p>a <a href="https://ok.example/">ok.example</a> @x #y</p>');
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 &amp; <b>B</b>", siteName: "Ok", image: "https://cdn.example/i.png" })).toEqual({
type: "linkPreview",
href: "https://ok.example/a",
host: "ok.example",
title: "A & <b>B</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 = <div dangerouslySetInnerHTML={{ __html: post.html }} />;",
"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 = <div>{post.text}</div>; el.textContent = s;")).toEqual([]);
});
});