Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500. Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
import "../setup.ts";
|
|
import { assertEquals } from "@std/assert";
|
|
import {
|
|
extractBestIcon,
|
|
extractFirstContentImage,
|
|
extractJsonLd,
|
|
extractLargeImage,
|
|
extractMaskIconColor,
|
|
extractMetaName,
|
|
extractOgTag,
|
|
extractPageTitle,
|
|
extractThemeColor,
|
|
normalizeCssColor,
|
|
} from "../../remote/html.ts";
|
|
|
|
const BASE = "https://example.com/articles/1";
|
|
|
|
Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => {
|
|
const page = `
|
|
<meta property="og:title" content="Tom & Jerry — ❤">
|
|
<meta content='Desc' property='og:description'>
|
|
<meta name="twitter:title" content="TW">
|
|
<meta name="og:title" content="wrong attribute">`;
|
|
assertEquals(extractOgTag(page, "title"), "Tom & Jerry — ❤");
|
|
assertEquals(extractOgTag(page, "description"), "Desc");
|
|
assertEquals(extractOgTag(page, "image"), undefined);
|
|
assertEquals(extractMetaName(page, "twitter:title"), "TW");
|
|
});
|
|
|
|
Deno.test("extractOgTag: content never spans into the next tag", () => {
|
|
const page = `<meta property="og:title"><meta name="x" content="leak">`;
|
|
assertEquals(extractOgTag(page, "title"), undefined);
|
|
});
|
|
|
|
Deno.test("extractPageTitle", () => {
|
|
assertEquals(
|
|
extractPageTitle("<title lang=en> A <b> </title>"),
|
|
"A <b>",
|
|
);
|
|
assertEquals(extractPageTitle("<p>none</p>"), undefined);
|
|
});
|
|
|
|
Deno.test("extractThemeColor: unscoped, then light, then any", () => {
|
|
assertEquals(
|
|
extractThemeColor(`
|
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
|
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff">`),
|
|
"#fff",
|
|
);
|
|
assertEquals(
|
|
extractThemeColor(`
|
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
|
|
<meta name="theme-color" content="#123">`),
|
|
"#123",
|
|
);
|
|
assertEquals(
|
|
extractThemeColor(
|
|
`<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">`,
|
|
),
|
|
"#000",
|
|
);
|
|
assertEquals(
|
|
extractMaskIconColor(
|
|
`<link rel="mask-icon" href="/m.svg" color="#5bbad5">`,
|
|
),
|
|
"#5bbad5",
|
|
);
|
|
});
|
|
|
|
Deno.test("normalizeCssColor", () => {
|
|
assertEquals(normalizeCssColor("#ABC"), "#aabbcc");
|
|
assertEquals(normalizeCssColor("#abcd"), "#aabbcc");
|
|
assertEquals(normalizeCssColor("#112233"), "#112233");
|
|
assertEquals(normalizeCssColor("#11223380"), "#112233");
|
|
assertEquals(normalizeCssColor(" Navy "), "#000080");
|
|
assertEquals(normalizeCssColor("rgb(255, 0, 128)"), "#ff0080");
|
|
assertEquals(normalizeCssColor("rgba(300 -5 50% / .5)"), "#ff0080");
|
|
assertEquals(normalizeCssColor("#12345"), undefined);
|
|
assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined);
|
|
assertEquals(normalizeCssColor("rgb(1,2)"), undefined);
|
|
assertEquals(normalizeCssColor(undefined), undefined);
|
|
});
|
|
|
|
Deno.test("extractJsonLd: @graph, arrays and image shapes", () => {
|
|
const ld = (data: unknown) =>
|
|
`<script type="application/ld+json">${JSON.stringify(data)}</script>`;
|
|
assertEquals(
|
|
extractJsonLd(
|
|
ld({
|
|
"@graph": [{ "@type": "WebSite" }, {
|
|
headline: "Head",
|
|
image: { url: "https://i/1.jpg" },
|
|
}],
|
|
}),
|
|
),
|
|
{ title: "Head", description: undefined, thumbnailUrl: "https://i/1.jpg" },
|
|
);
|
|
assertEquals(
|
|
extractJsonLd(ld({ name: ["N"], image: ["https://i/2.jpg"] })).thumbnailUrl,
|
|
"https://i/2.jpg",
|
|
);
|
|
assertEquals(
|
|
extractJsonLd(ld({ name: "N", image: "/relative.jpg" })).thumbnailUrl,
|
|
undefined,
|
|
);
|
|
// Invalid JSON is skipped for the next block.
|
|
assertEquals(
|
|
extractJsonLd(
|
|
`<script type="application/ld+json">{oops</script>${
|
|
ld({ name: "Second" })
|
|
}`,
|
|
).title,
|
|
"Second",
|
|
);
|
|
assertEquals(extractJsonLd("<p></p>"), {});
|
|
});
|
|
|
|
Deno.test("image extraction: large images, content images, relative URLs", () => {
|
|
const page = `
|
|
<img src="data:image/png;base64,xx" width="500" height="500">
|
|
<img src="/small.jpg" width="50" height="50">
|
|
<img src="/img/site-logo.png">
|
|
<img src="/icons/sprite.svg">
|
|
<img src="/photos/cat.jpg" width="640" height="480">`;
|
|
assertEquals(
|
|
extractLargeImage(page, BASE),
|
|
"https://example.com/photos/cat.jpg",
|
|
);
|
|
assertEquals(
|
|
extractFirstContentImage(page, BASE),
|
|
"https://example.com/small.jpg",
|
|
);
|
|
assertEquals(
|
|
extractFirstContentImage(`<img src="//cdn.test/a.jpg">`, BASE),
|
|
"https://cdn.test/a.jpg",
|
|
);
|
|
});
|
|
|
|
Deno.test("extractBestIcon: SVG beats everything, else the largest", () => {
|
|
assertEquals(
|
|
extractBestIcon(
|
|
`<link rel="icon" href="/16.png" sizes="16x16">
|
|
<link rel="apple-touch-icon" href="/180.png" sizes="180x180">
|
|
<link rel="stylesheet" href="/x.css">`,
|
|
BASE,
|
|
),
|
|
"https://example.com/180.png",
|
|
);
|
|
assertEquals(
|
|
extractBestIcon(
|
|
`<link rel="icon" href="/180.png" sizes="180x180">
|
|
<link rel="icon" type="image/svg+xml" href="/icon">`,
|
|
BASE,
|
|
),
|
|
"https://example.com/icon",
|
|
);
|
|
assertEquals(extractBestIcon("<p></p>", BASE), undefined);
|
|
});
|