Files
web_huakui_web/tests/images.test.ts
T
谢宇宁andClaude Opus 5.5 d2ddb1c321 [Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:31 +07:00

276 lines
9.4 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
imageCDNBases,
imageSourceURLs,
validProfileImage,
} from "../src/lib/media/image-source";
import {
decodeImageBytes,
decryptImageBytes,
imageMime,
resolveImage,
} from "../src/lib/media/images";
const primary = "https://primary.images.test/assets/";
const backup = "https://backup.images.test/media/";
const signedPath = "v3/image/cover.jpg?ticket=a%2Bb%3D&expires=99&path=%2Ffoo";
const bytesOf = (path: string): Uint8Array =>
new Uint8Array(readFileSync(new URL(path, import.meta.url)));
const png = bytesOf("../public/static/favicon.png");
const fixtures = [
["PNG", "image/png", png],
[
"JPEG",
"image/jpeg",
bytesOf("../public/static/images/profile/water-margin.jpg"),
],
[
"WebP",
"image/webp",
new Uint8Array(
Buffer.from("UklGRhwAAABXRUJQVlA4TBAAAAAvAAAAAAcQ/S9g/wMR0f8A", "base64"),
),
],
[
"GIF",
"image/gif",
new Uint8Array(
Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"),
),
],
] as const;
const bufferOf = (bytes: Uint8Array): ArrayBuffer =>
Uint8Array.from(bytes).buffer;
test("CDN bases retain deployment paths, deduplicate, and reject unsafe configuration", () => {
assert.deepEqual(
imageCDNBases(
" https://primary.images.test/assets ",
primary,
"http://backup.images.test/media/",
"https://user:secret@private.images.test/",
"https://query.images.test/?token=secret",
"https://hash.images.test/#fragment",
"javascript:alert(1)",
"data:image/png;base64,abc",
"//relative.images.test/",
"https://",
"",
null,
42,
{},
),
[primary, "http://backup.images.test/media/"],
);
});
test("relative API images use the CDN base or the site root on every page", () => {
for (const source of [signedPath, "/" + signedPath]) {
assert.deepEqual(imageSourceURLs(source), ["/" + signedPath]);
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [
primary + signedPath,
backup + signedPath,
]);
}
});
test("absolute signed image URLs remain byte-for-byte intact and only configured CDN paths gain fallbacks", () => {
const source = primary + signedPath;
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [
source,
backup + signedPath,
]);
assert.deepEqual(imageSourceURLs(backup + signedPath, [primary, backup]), [
backup + signedPath,
source,
]);
for (const outside of [
"https://external.images.test/" + signedPath,
"https://primary.images.test/assets-other/" + signedPath,
])
assert.deepEqual(imageSourceURLs(outside, [primary, backup]), [outside]);
});
test("static, inline and blob sources bypass rewriting while executable sources are rejected", () => {
for (const source of [
"/static/brand/placeholder.svg?v=1",
"data:image/png;base64,iVBORw0KGgo=",
"blob:https://site.test/image-id",
])
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [source]);
for (const source of [
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///private/image.png",
"vbscript:msgbox(1)",
"https://",
"",
])
assert.deepEqual(imageSourceURLs(source, [primary, backup]), []);
});
test("profile avatars accept real API image paths without accepting executable or malformed HTTP URLs", () => {
for (const source of [
"https://avatars.images.test/face.jpg?signature=a%2Bb%3D",
"http://avatars.images.test/face.png",
"/static/images/profile/avatar.png?v=1",
signedPath,
"/" + signedPath,
])
assert.equal(validProfileImage(source), source);
for (const source of [
null,
42,
{},
"",
"javascript:alert(1)",
"data:text/html,unsafe",
"https://",
"http://[malformed",
"https://bad host/image.png",
"ordinary-relative-avatar.png",
])
assert.equal(validProfileImage(source), "");
});
test("image decoding accepts complete raw and legacy XOR images without mutating their input", () => {
for (const [name, mime, original] of fixtures) {
const plainBuffer = bufferOf(original);
assert.deepEqual(decodeImageBytes(plainBuffer), original, name + " raw");
assert.equal(imageMime(decodeImageBytes(plainBuffer)), mime, name + " type");
assert.deepEqual(new Uint8Array(plainBuffer), original, name + " raw input");
const encrypted = decryptImageBytes(bufferOf(original));
const encryptedBuffer = bufferOf(encrypted);
assert.deepEqual(
decodeImageBytes(encryptedBuffer),
original,
name + " encrypted",
);
assert.deepEqual(
new Uint8Array(encryptedBuffer),
encrypted,
name + " encrypted input",
);
}
});
test("image decoding rejects empty bodies and non-image API error responses", () => {
for (const value of [
"",
'{"code":404,"data":null,"message":"image missing"}',
"<!doctype html><html><body>CDN unavailable</body></html>",
"upstream unavailable",
]) {
const bytes = new TextEncoder().encode(value);
assert.throws(() => decodeImageBytes(bufferOf(bytes)), value || "empty");
if (bytes.length)
assert.throws(() =>
decodeImageBytes(bufferOf(decryptImageBytes(bufferOf(bytes)))),
);
}
});
test("image fetching validates bytes, retries alternate CDNs, and only caches successful images", async (t) => {
const originalFetch = globalThis.fetch;
const created: string[] = [];
const responseFor = (bytes: Uint8Array, contentType = "image/png") =>
new Response(bufferOf(bytes), { headers: { "Content-Type": contentType } });
const captureBlob = async (url: string, expected: Uint8Array, mime: string) => {
assert.match(url, /^blob:/);
created.push(url);
const response = await originalFetch(url);
assert.equal(response.headers.get("content-type"), mime);
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), expected);
};
try {
await t.test("HTTP failure uses the next CDN and retains its successful cache", async () => {
const path = "v3/image/http-fallback-20261012.png?signature=x%2By%3D";
const calls: string[] = [];
globalThis.fetch = async (input) => {
const url = String(input);
calls.push(url);
return url === primary + path
? new Response("CDN unavailable", { status: 503 })
: responseFor(decryptImageBytes(bufferOf(png)));
};
const first = await resolveImage(path, true, [primary, backup]);
await captureBlob(first, png, "image/png");
assert.deepEqual(calls, [primary + path, backup + path]);
assert.equal(await resolveImage(path, false, [primary, backup]), first);
assert.equal(calls.length, 2);
});
await t.test("a JSON 200 response is rejected before trying the alternate CDN", async () => {
const path = "v3/image/json-fallback-20261012.png";
const calls: string[] = [];
globalThis.fetch = async (input) => {
const url = String(input);
calls.push(url);
return url === primary + path
? new Response('{"code":404,"data":null}', {
headers: { "Content-Type": "application/json" },
})
: responseFor(png);
};
await captureBlob(
await resolveImage(primary + path, false, [primary, backup]),
png,
"image/png",
);
assert.deepEqual(calls, [primary + path, backup + path]);
});
await t.test("a missing CDN roots the API path before requesting valid bytes", async () => {
const path = "v3/image/no-cdn-20261012.png?signature=a%2Bb%3D";
const calls: string[] = [];
globalThis.fetch = async (input) => {
calls.push(String(input));
return responseFor(png);
};
await captureBlob(await resolveImage(path), png, "image/png");
assert.deepEqual(calls, ["/" + path]);
});
await t.test("invalid image responses are not cached and the same URL can recover", async () => {
const source = "https://unconfigured.images.test/no-cache-20261012.png";
let count = 0;
let recovered = false;
globalThis.fetch = async () => {
count++;
return recovered
? responseFor(png)
: new Response('{"code":500,"tip":"failed"}', {
headers: { "Content-Type": "image/png" },
});
};
await assert.rejects(resolveImage(source));
await assert.rejects(resolveImage(source));
assert.equal(count, 2, "invalid responses must trigger a fresh request");
recovered = true;
const resolved = await resolveImage(source);
await captureBlob(resolved, png, "image/png");
assert.equal(count, 3);
assert.equal(await resolveImage(source), resolved);
assert.equal(count, 3, "successful bytes may be cached");
});
await t.test("non-image sources and illegal protocols never enter the image fetcher", async () => {
let count = 0;
globalThis.fetch = async () => {
count++;
throw new Error("Unexpected image request");
};
const source = "/static/brand/placeholder.svg?v=image-test";
assert.equal(await resolveImage(source), source);
assert.equal(count, 0);
await assert.rejects(resolveImage("javascript:alert(1)"));
assert.equal(count, 0);
});
} finally {
globalThis.fetch = originalFetch;
for (const url of created) URL.revokeObjectURL(url);
}
});