4153 lines
141 KiB
JavaScript
4153 lines
141 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createServer, request as httpRequest } from "node:http";
|
|
import { spawn } from "node:child_process";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { rm, readFile } from "node:fs/promises";
|
|
const appContract = JSON.parse(
|
|
await readFile(
|
|
new URL("./fixtures/sgdj-app-contract.json", import.meta.url),
|
|
"utf8",
|
|
),
|
|
).operations;
|
|
let strictContract = false;
|
|
import { join } from "node:path";
|
|
|
|
let mock,
|
|
next,
|
|
base,
|
|
output = "";
|
|
let mockOrigin,
|
|
seoSnapshotPath,
|
|
configFailure = "";
|
|
let downloadAddress = "https://example.com/download";
|
|
let seoConfig = {},
|
|
categories = [],
|
|
listPayload,
|
|
detailSeo;
|
|
let chapterPayload, videoPayload, chapterListPayload, commentPayload;
|
|
let tagOptionsPayload;
|
|
let requireSeoAuth = false,
|
|
searchFailure = false,
|
|
searchPayload;
|
|
let responseOverrides = {};
|
|
let upstreamKeySlash = false;
|
|
const defaultHeader = {
|
|
categoryList: [
|
|
{ id: 1, name: "首页", urlValue: "", categoryType: 1 },
|
|
{ id: 2, name: "短剧", urlValue: "/shorts/", categoryType: 1 },
|
|
{ id: 3, name: "专题", urlValue: "/topics/", categoryType: 1 },
|
|
{ id: 4, name: "热榜", urlValue: "/hot", categoryType: 1 },
|
|
{ id: 5, name: "回家的路", urlValue: "", categoryType: 1 },
|
|
{ id: 6, name: "福利应用", urlValue: "", categoryType: 1 },
|
|
],
|
|
};
|
|
let headerPayload = defaultHeader,
|
|
headerFailure = false;
|
|
let appsPayload, packagesPayload, accountListPayload, transactionsPayload;
|
|
let blockedDetail, footerPayload, themePayload, richTextPayload;
|
|
const calls = [];
|
|
const testEnvironment =
|
|
process.env.TEST_APP_ENV === "test" ? "test" : "production";
|
|
const testBuildDirectory = testEnvironment === "test" ? ".next-test" : ".next";
|
|
const fixture = {
|
|
id: 23456,
|
|
title: "接口剧名 <script>alert(1)</script>",
|
|
cover: "https://example.com/poster.webp",
|
|
totalEpisode: 7,
|
|
countLike: 1234,
|
|
tagNames: ["都市"],
|
|
showOnBanner: true,
|
|
};
|
|
let homePayload = {
|
|
homeBannerData: [fixture],
|
|
homeConfigData: [
|
|
{ id: 31, status: true, name: "后台配置标题", type: 1, list: [fixture] },
|
|
],
|
|
};
|
|
const playlist =
|
|
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="/enkey?token=a%2Fb"\n#EXTINF:2,\nhttps://cdn.example/segment.ts?md=signed+value\n#EXT-X-ENDLIST\n';
|
|
async function listen(server) {
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
return server.address().port;
|
|
}
|
|
function assertPageLinksHaveTrailingSlash(html) {
|
|
for (const match of html.matchAll(
|
|
/<a\b[^>]*\bhref="(\/[^"#?]*)(?:[?#][^"]*)?"/g,
|
|
))
|
|
assert.ok(match[1].endsWith("/"), match[1]);
|
|
}
|
|
|
|
async function startNext(environment = {}) {
|
|
const reservation = createServer();
|
|
const port = await listen(reservation);
|
|
await new Promise((resolve) => reservation.close(resolve));
|
|
base = `http://127.0.0.1:${port}`;
|
|
output = "";
|
|
next = spawn(
|
|
process.execPath,
|
|
[
|
|
"node_modules/next/dist/bin/next",
|
|
"start",
|
|
"--hostname",
|
|
"0.0.0.0",
|
|
"--port",
|
|
String(port),
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
APP_ENV: testEnvironment,
|
|
APP_DIST_DIR: testBuildDirectory,
|
|
UPSTREAM_API_URL: mockOrigin,
|
|
API_BASE_URL: "http://127.0.0.1:1",
|
|
PUBLIC_DATA_CACHE_TTL_MS: "0",
|
|
...environment,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
next.stdout.on("data", (chunk) => {
|
|
output += chunk.toString();
|
|
});
|
|
next.stderr.on("data", (chunk) => {
|
|
output += chunk.toString();
|
|
});
|
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
try {
|
|
if ((await fetch(base + "/favicon.png")).ok) return;
|
|
} catch {}
|
|
if (next.exitCode !== null) throw new Error(output);
|
|
await delay(100);
|
|
}
|
|
throw new Error("Test server did not start: " + output);
|
|
}
|
|
|
|
async function stopNext() {
|
|
if (!next || next.exitCode !== null) return;
|
|
const stopped = new Promise((resolve) => next.once("exit", resolve));
|
|
next.kill("SIGTERM");
|
|
await stopped;
|
|
}
|
|
|
|
test("deployment health endpoint bypasses guest creation, avoids redirects and never caches", async () => {
|
|
for (const path of ["/healthz", "/healthz/"]) {
|
|
const previous = calls.length;
|
|
const response = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("set-cookie"), null);
|
|
assert.match(response.headers.get("cache-control"), /no-store/);
|
|
assert.match(response.headers.get("x-robots-tag"), /noindex/);
|
|
assert.deepEqual(await response.json(), {
|
|
status: "ok",
|
|
environment: testEnvironment,
|
|
application: "jha-1050-jgdj-web",
|
|
});
|
|
assert.equal(calls.length, previous);
|
|
}
|
|
const head = await fetch(base + "/healthz", {
|
|
method: "HEAD",
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(head.status, 200);
|
|
assert.equal(await head.text(), "");
|
|
});
|
|
|
|
before(
|
|
async () => {
|
|
mock = createServer(async (request, response) => {
|
|
let raw = "";
|
|
for await (const chunk of request) raw += chunk;
|
|
const url = new URL(request.url, "http://local");
|
|
calls.push({
|
|
path: url.pathname,
|
|
search: url.search,
|
|
method: request.method,
|
|
auth: request.headers.authorization,
|
|
cookie: request.headers.cookie,
|
|
range: request.headers.range,
|
|
device: request.headers["x-user-agent"],
|
|
ua: request.headers["user-agent"],
|
|
accept: request.headers.accept,
|
|
contentType: request.headers["content-type"],
|
|
ip: request.headers["x-real-ip"],
|
|
forwarded: request.headers["x-forwarded-for"],
|
|
sdk: request.headers["x-sdk-common"],
|
|
trackerId: request.headers["x-tracker-request-id"],
|
|
trackerEvent: request.headers["x-tracker-event-name"],
|
|
trackerContext: request.headers["x-dc-request-context"],
|
|
body: raw,
|
|
});
|
|
if (url.pathname.startsWith("/h5/m3u8/")) {
|
|
if (url.pathname.endsWith("redirect.m3u8")) {
|
|
response.writeHead(302, { Location: "/unexpected" });
|
|
response.end();
|
|
return;
|
|
}
|
|
if (url.pathname.endsWith("missing.m3u8")) {
|
|
response.writeHead(404);
|
|
response.end();
|
|
return;
|
|
}
|
|
if (url.pathname.endsWith("denied.m3u8")) {
|
|
response.writeHead(200, { "Content-Type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({ code: 1000, msg: "expired signed URL" }),
|
|
);
|
|
return;
|
|
}
|
|
if (url.pathname.endsWith("invalid.m3u8")) {
|
|
response.writeHead(200, { "Content-Type": "text/html" });
|
|
response.end("<html>Error</html>");
|
|
return;
|
|
}
|
|
if (url.pathname.endsWith(".ts")) {
|
|
response.writeHead(206, {
|
|
"Content-Type": "application/octet-stream",
|
|
"Content-Range": "bytes 0-2/10",
|
|
"Accept-Ranges": "bytes",
|
|
});
|
|
response.end(Buffer.from([9, 8, 7]));
|
|
return;
|
|
}
|
|
response.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
response.end(playlist);
|
|
return;
|
|
}
|
|
if (
|
|
["/enkey", "/enkey/", "/newenkey", "/newenkey/"].includes(url.pathname)
|
|
) {
|
|
const canonical =
|
|
url.pathname.replace(/\/$/, "") + (upstreamKeySlash ? "/" : "");
|
|
if (url.pathname !== canonical) {
|
|
response.writeHead(301, { Location: canonical + url.search });
|
|
response.end();
|
|
return;
|
|
}
|
|
response.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
response.end(
|
|
url.searchParams.has("invalid")
|
|
? Buffer.from(JSON.stringify({ code: 1000, msg: "bad key" }))
|
|
: Buffer.from(Array.from({ length: 16 }, (_, index) => index)),
|
|
);
|
|
return;
|
|
}
|
|
const path = url.pathname.replace("/api/app/", "");
|
|
if (Object.hasOwn(responseOverrides, path)) {
|
|
const {
|
|
status = 200,
|
|
code = 200,
|
|
data = null,
|
|
raw: overrideRaw,
|
|
} = responseOverrides[path];
|
|
response.writeHead(status, { "Content-Type": "application/json" });
|
|
response.end(overrideRaw ?? JSON.stringify({ code, data }));
|
|
return;
|
|
}
|
|
if (path === "playlet/detail" && blockedDetail)
|
|
await blockedDetail.promise;
|
|
if (strictContract) {
|
|
const op = appContract[request.method + " " + path];
|
|
const invalid =
|
|
!op ||
|
|
[...url.searchParams.keys()].some((key) => !op.query.includes(key)) ||
|
|
op.requiredQuery.some((key) => !url.searchParams.has(key));
|
|
if (invalid) {
|
|
response.writeHead(400, { "Content-Type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
code: 400,
|
|
msg: "Undocumented endpoint or query",
|
|
data: null,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
let data = { list: [], totalCount: 0 };
|
|
let code = 200;
|
|
if (path === "auth/guest")
|
|
data = {
|
|
token: "test-session-secret",
|
|
userId: 77,
|
|
nickName: "真实接口用户",
|
|
type: 0,
|
|
};
|
|
else if (path === "auth/login") {
|
|
const body = JSON.parse(raw);
|
|
if (body.account === "wrong") code = 1000;
|
|
data = {
|
|
...(body.account === "missing-token"
|
|
? {}
|
|
: { token: "test-registered-secret" }),
|
|
userId: 88,
|
|
nickName: "正式用户",
|
|
type: 1,
|
|
isRegister: true,
|
|
};
|
|
} else if (path === "auth/register")
|
|
data = {
|
|
token: "test-bound-secret",
|
|
userId: 77,
|
|
nickName: "绑定游客",
|
|
type: 1,
|
|
isRegister: true,
|
|
};
|
|
else if (path === "auth/getInfo") {
|
|
if (request.headers.authorization === "expired") code = 1000;
|
|
else if (request.headers.authorization === "test-session-secret")
|
|
data = { userId: 77, nickName: "真实接口用户", type: 0, recharge: 90 };
|
|
else if (request.headers.authorization === "test-registered-secret")
|
|
data = { userId: 88, nickName: "正式用户", type: 1 };
|
|
else if (request.headers.authorization === "http-expired") {
|
|
response.writeHead(401, { "Content-Type": "application/json" });
|
|
response.end(JSON.stringify({ code: 401, data: null }));
|
|
return;
|
|
} else if (
|
|
request.headers.authorization === "registered-user-a-secret" ||
|
|
request.headers.authorization === "registered-user-b-secret"
|
|
) {
|
|
const first =
|
|
request.headers.authorization === "registered-user-a-secret";
|
|
data = {
|
|
userId: first ? 101 : 202,
|
|
nickName: first ? "用户甲" : "用户乙",
|
|
recharge: first ? 111 : 222,
|
|
type: 1,
|
|
isRegister: true,
|
|
token: request.headers.authorization,
|
|
password: "must-not-leak",
|
|
};
|
|
} else
|
|
data = {
|
|
userId: 77,
|
|
nickName: "接口昵称",
|
|
token: "must-not-leak",
|
|
recharge: 90,
|
|
};
|
|
} else if (path === "config/info")
|
|
data = {
|
|
seoInfo: { webSiteName: "黄果短剧" },
|
|
...seoConfig,
|
|
config: {
|
|
foreverUrlAddr: "https://default.drama.test",
|
|
telegram: "https://example.com/contact",
|
|
tgGroup: "https://example.com/group",
|
|
twitterAddr: "https://example.com/twitter",
|
|
officialAppAddr: downloadAddress,
|
|
...seoConfig.config,
|
|
},
|
|
navPageList: Object.hasOwn(seoConfig, "navPageList")
|
|
? seoConfig.navPageList
|
|
: [
|
|
{ line: 2, url: "https://example.com/entry-two" },
|
|
{ line: 1, url: "https://example.com/entry-one" },
|
|
{ line: 3, url: "javascript:alert(1)" },
|
|
],
|
|
};
|
|
else if (path === "navigation/header") {
|
|
data = headerPayload;
|
|
if (headerFailure) code = 500;
|
|
} else if (path === "config/footer/info" && footerPayload)
|
|
data = footerPayload;
|
|
else if (path === "config/richtext/list") {
|
|
const titles = new Set(
|
|
(url.searchParams.get("titles") || "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean),
|
|
);
|
|
const list = Array.isArray(richTextPayload) ? richTextPayload : [];
|
|
data = {
|
|
list: titles.size
|
|
? list.filter((item) => titles.has(item.title))
|
|
: list,
|
|
};
|
|
}
|
|
else if (path === "translation/category/list")
|
|
data = { categoryList: categories };
|
|
else if (path === "theme/list")
|
|
data = themePayload || {
|
|
list: [7, 8, 10404].map((id) => ({
|
|
id,
|
|
name: "题材" + id,
|
|
slug: id === 7 ? "urban-topic" : undefined,
|
|
})),
|
|
};
|
|
else if (path === "applist/list" && appsPayload) data = appsPayload;
|
|
else if (path === "recharge/package/list" && packagesPayload)
|
|
data = packagesPayload;
|
|
else if (
|
|
[
|
|
"playlet/history/list",
|
|
"playlet/collect/list",
|
|
"playlet/like/list",
|
|
"playlet/bought/list",
|
|
].includes(path) &&
|
|
accountListPayload
|
|
)
|
|
data =
|
|
typeof accountListPayload === "function"
|
|
? accountListPayload(path, request)
|
|
: accountListPayload;
|
|
else if (path === "coin/trans/list" && transactionsPayload)
|
|
data = transactionsPayload;
|
|
else if (path === "playlet/home") data = homePayload;
|
|
else if (path === "playlet/home-config/list") {
|
|
const modules =
|
|
homePayload.modules ||
|
|
(homePayload.homeConfigData || [])
|
|
.filter((block) => block && block.status)
|
|
.map((block) => ({
|
|
id: block.id,
|
|
slug: block.slug,
|
|
title: block.name,
|
|
desc: block.desc,
|
|
moduleType: block.type === 3 ? 2 : 1,
|
|
list: block.list,
|
|
topicList: block.themeList,
|
|
}));
|
|
const module = modules.find((item) =>
|
|
url.searchParams.has("slug")
|
|
? item.slug === url.searchParams.get("slug")
|
|
: item.id === Number(url.searchParams.get("id")),
|
|
);
|
|
if (!module) {
|
|
code = 5009;
|
|
data = null;
|
|
} else {
|
|
const page = Number(url.searchParams.get("pageNum") || 1),
|
|
size = Number(url.searchParams.get("pageSize") || 24),
|
|
items =
|
|
(module.moduleType === 2 ? module.topicList : module.list) || [];
|
|
data = {
|
|
config: module,
|
|
list:
|
|
module.moduleType === 1
|
|
? items.slice((page - 1) * size, page * size)
|
|
: [],
|
|
topicList:
|
|
module.moduleType === 2
|
|
? items.slice((page - 1) * size, page * size)
|
|
: [],
|
|
totalCount: items.length,
|
|
pageNum: page,
|
|
pageSize: size,
|
|
};
|
|
}
|
|
} else if (path === "playlet/tag/options")
|
|
data = tagOptionsPayload || { list: [{ id: 9, name: "测试标签" }] };
|
|
else if (path === "playlet/search")
|
|
data = JSON.stringify(
|
|
searchPayload || {
|
|
list: [fixture],
|
|
totalCount: 1,
|
|
pageNum: 1,
|
|
pageSize: 24,
|
|
},
|
|
);
|
|
else if (path === "playlet/guess-like/list")
|
|
data = {
|
|
list: [{ ...fixture, id: 23458, title: "同类推荐短剧" }],
|
|
totalCount: 1,
|
|
};
|
|
else if (path === "playlet/detail")
|
|
data = {
|
|
...fixture,
|
|
...detailSeo,
|
|
isCollect: 2,
|
|
isLike: 2,
|
|
playletChapterInfo: [
|
|
{ id: 90001, currentEpisode: 1, canWatch: true },
|
|
],
|
|
};
|
|
else if (path === "video/detail" && videoPayload) data = videoPayload;
|
|
else if (
|
|
listPayload &&
|
|
[
|
|
"playlet/hot/rank/list",
|
|
"playlet/topic/list",
|
|
"playlet/topic/detail",
|
|
"playlet/all/list",
|
|
"playlet/operate/list",
|
|
"playlet/tag/list",
|
|
].includes(path)
|
|
)
|
|
data =
|
|
typeof listPayload === "function"
|
|
? listPayload(path, url)
|
|
: listPayload;
|
|
else if (path === "playlet/chapter/list")
|
|
data = chapterListPayload || {
|
|
list: [{ id: 90001, currentEpisode: 1, canWatch: true }],
|
|
};
|
|
else if (path === "playlet/chapter/detail")
|
|
data = chapterPayload || {
|
|
id: 90001,
|
|
playletId: 23456,
|
|
currentEpisode: 1,
|
|
canWatch: false,
|
|
needUnlock: true,
|
|
singleCoins: 13,
|
|
balance: 90,
|
|
};
|
|
else if (path === "playlet/chapter/unlock") {
|
|
const body = JSON.parse(raw);
|
|
data = { ...body, balance: 77 };
|
|
} else if (path === "playlet/collect/set") {
|
|
if (
|
|
request.headers.authorization === "expired" ||
|
|
JSON.parse(raw).id === 999
|
|
)
|
|
code = 1000;
|
|
else data = { id: 23456, isCollect: 1, countCollect: 1 };
|
|
} else if (path === "comment/list")
|
|
data = commentPayload || { list: [], totalCount: 0 };
|
|
if (path === "playlet/topic/detail" && !data.id) {
|
|
const slug = url.searchParams.get("topicSlug");
|
|
if (slug) {
|
|
const topic = listPayload?.list?.find((item) => item.slug === slug);
|
|
if (topic) data = { ...data, ...topic };
|
|
else {
|
|
code = 5009;
|
|
data = null;
|
|
}
|
|
} else data = { ...data, id: Number(url.searchParams.get("id")) };
|
|
}
|
|
if (path === "playlet/search" && searchFailure) {
|
|
code = 5100;
|
|
data = null;
|
|
}
|
|
if (path === "config/info" && configFailure) {
|
|
if (configFailure === "http" || configFailure === "timeout") {
|
|
if (configFailure === "timeout") await delay(5200);
|
|
response.writeHead(503, { "Content-Type": "application/json" });
|
|
response.end(JSON.stringify({ code: 503, data: null }));
|
|
return;
|
|
}
|
|
if (configFailure === "business") code = 500;
|
|
if (configFailure === "malformed") data = null;
|
|
}
|
|
if (
|
|
requireSeoAuth &&
|
|
["playlet/detail", "comment/list", "playlet/search"].includes(path) &&
|
|
!request.headers.authorization
|
|
)
|
|
code = 1000;
|
|
response.writeHead(200, { "Content-Type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
code,
|
|
data,
|
|
msg: code === 200 ? "success" : "session expired",
|
|
}),
|
|
);
|
|
});
|
|
const mockPort = await listen(mock);
|
|
mockOrigin = `http://127.0.0.1:${mockPort}`;
|
|
seoSnapshotPath = join(
|
|
process.cwd(),
|
|
testBuildDirectory,
|
|
"cache",
|
|
"seo",
|
|
createHash("sha256").update(mockOrigin).digest("hex") + ".json",
|
|
);
|
|
await rm(seoSnapshotPath, { force: true });
|
|
await startNext();
|
|
},
|
|
{ timeout: 15000 },
|
|
);
|
|
|
|
after(async () => {
|
|
await stopNext();
|
|
if (mock) await new Promise((resolve) => mock.close(resolve));
|
|
if (seoSnapshotPath) await rm(seoSnapshotPath, { force: true });
|
|
});
|
|
|
|
test("a cold configuration failure never uses request/forwarded hosts as canonical or publisher URLs", async () => {
|
|
try {
|
|
configFailure = "http";
|
|
const response = await fetch(base + "/info/about-us/", {
|
|
headers: {
|
|
"X-Forwarded-Host": "untrusted.entry.test",
|
|
"X-Forwarded-Proto": "https",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text(),
|
|
head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
|
|
assert.ok(!head.includes('rel="canonical"'));
|
|
assert.ok(!head.includes('property="og:url"'));
|
|
assert.ok(!head.includes("untrusted.entry.test"));
|
|
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
|
assert.match(head, /<title>关于我们 - 黄果短剧<\/title>/);
|
|
} finally {
|
|
configFailure = "";
|
|
}
|
|
});
|
|
|
|
test("robots.txt serves crawler text without sessions and advertises the configured absolute sitemap", async () => {
|
|
const before = calls.length;
|
|
for (const agent of ["Googlebot", "YandexBot", "bingbot"])
|
|
for (const method of ["GET", "HEAD"]) {
|
|
const response = await fetch(base + "/robots.txt", {
|
|
method,
|
|
redirect: "manual",
|
|
headers: {
|
|
"User-Agent": agent,
|
|
"X-Forwarded-Host": "untrusted.example",
|
|
Cookie: "drama_session=expired",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal(response.headers.get("set-cookie"), null);
|
|
assert.match(response.headers.get("content-type"), /^text\/plain\b/);
|
|
const body = await response.text();
|
|
if (method === "HEAD") {
|
|
assert.equal(body, "");
|
|
continue;
|
|
}
|
|
assert.match(body, /User-Agent: \*/i);
|
|
assert.ok(!body.includes("<html"));
|
|
assert.ok(!body.includes("untrusted.example"));
|
|
if (testEnvironment === "production") {
|
|
assert.match(body, /^Allow: \/$/m);
|
|
assert.match(body, /^Disallow: \/api\/$/m);
|
|
assert.match(body, /^Disallow: \/healthz$/m);
|
|
assert.match(
|
|
body,
|
|
/^Sitemap: https:\/\/default\.drama\.test\/sitemap\.xml$/m,
|
|
);
|
|
assert.ok(!/^Disallow: \/$/m.test(body));
|
|
} else {
|
|
assert.match(body, /^Disallow: \/$/m);
|
|
assert.ok(!body.includes("Sitemap:"));
|
|
}
|
|
}
|
|
const reads = calls.slice(before);
|
|
assert.ok(
|
|
reads.every((call) => call.path === "/api/app/config/info" && !call.auth),
|
|
"only public site configuration is needed",
|
|
);
|
|
});
|
|
|
|
test("robots sitemap uses permanent-domain precedence, survives configuration failure and never guesses a host", async () => {
|
|
if (testEnvironment !== "production") return;
|
|
try {
|
|
seoConfig = {
|
|
config: { foreverUrlAddr: "https://robots.drama.test/nested/?source=1" },
|
|
newAddressInfo: { foreverUrlAddr: "https://backup.drama.test" },
|
|
};
|
|
let response = await fetch(base + "/robots.txt");
|
|
assert.equal(response.status, 200);
|
|
assert.match(
|
|
await response.text(),
|
|
/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m,
|
|
);
|
|
configFailure = "http";
|
|
response = await fetch(base + "/robots.txt", {
|
|
headers: { "X-Forwarded-Host": "fake.test" },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.match(
|
|
await response.text(),
|
|
/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m,
|
|
);
|
|
configFailure = "";
|
|
seoConfig = {
|
|
config: { foreverUrlAddr: "" },
|
|
newAddressInfo: { foreverUrlAddr: "https://backup.drama.test/base/" },
|
|
};
|
|
response = await fetch(base + "/robots.txt");
|
|
assert.match(
|
|
await response.text(),
|
|
/^Sitemap: https:\/\/backup\.drama\.test\/sitemap\.xml$/m,
|
|
);
|
|
} finally {
|
|
configFailure = "";
|
|
seoConfig = {};
|
|
}
|
|
});
|
|
|
|
test("the bare site origin and root slash both serve the homepage without redirecting to shorts", async () => {
|
|
for (const address of [base, base + "/"]) {
|
|
const response = await fetch(address, { redirect: "manual" });
|
|
const html = await response.text();
|
|
assert.equal(response.status, 200, address);
|
|
assert.equal(response.headers.get("location"), null, address);
|
|
assert.equal(new URL(response.url).pathname, "/");
|
|
assert.ok(html.includes('class="home-content"'));
|
|
assert.ok(html.includes("后台配置标题"));
|
|
assert.match(html, /<a href="\/"[^>]*data-page="home"/);
|
|
assert.match(html, /<a href="\/shorts\/"[^>]*data-page="shorts"/);
|
|
}
|
|
});
|
|
|
|
test("SSR uses upstream content, escapes HTML, and does not restore homepage filters", async () => {
|
|
const response = await fetch(base);
|
|
const html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(
|
|
(html.match(/<h1\b/g) || []).length,
|
|
1,
|
|
"only the homepage title may use h1, including hidden templates",
|
|
);
|
|
assert.match(
|
|
html,
|
|
/<h1 id="home-title" class="home-page-title">黄果短剧 · 精品原创 · 日更爽看<\/h1>/,
|
|
);
|
|
assert.match(
|
|
html,
|
|
/<h2 class="hero-feature-title"><a[^>]*>接口剧名 <script>alert\(1\)<\/script><\/a><\/h2>/,
|
|
);
|
|
assert.ok(html.includes("后台配置标题"));
|
|
assert.ok(html.includes("接口剧名 <script>"));
|
|
assert.ok(!html.includes("<script>alert(1)</script>"));
|
|
assert.ok(!html.includes("data-must-watch-sort"));
|
|
assert.ok(!html.includes("人人嫌猎户粗野"));
|
|
assert.ok(html.includes('href="/shorts/"'));
|
|
assert.ok(html.includes('href="/detail/23456/"'));
|
|
assert.ok(html.includes('href="/config/31/"'));
|
|
assert.ok(html.includes('data-src="https://example.com/poster.webp"'));
|
|
assert.ok(html.includes('src="/placeholder.png"'));
|
|
assert.ok(
|
|
!/<img\b[^>]*\ssrc="https:\/\/example.com\/poster.webp"/.test(html),
|
|
);
|
|
assert.ok(
|
|
!/href="#(?:page|info|member|detail|play|video|search)\//.test(html),
|
|
);
|
|
assert.ok(!html.includes('id="entry-navigation"'));
|
|
assert.ok(!html.includes("entry-navigation-pending"));
|
|
assert.match(
|
|
html,
|
|
/<dialog id="startup-grid" class="startup-popup" aria-label="应用推广">/,
|
|
);
|
|
assert.match(
|
|
html,
|
|
/<div class="startup-promotions" id="startup-promotions"><\/div>/,
|
|
);
|
|
assert.ok(!html.includes('id="header-download-qr"'));
|
|
assert.ok(!html.includes('id="download-qr-panel"'));
|
|
const entries = [
|
|
...html.matchAll(
|
|
/<(?:div|section|a)\b[^>]*\bdata-app-download-entry\b[^>]*>/g,
|
|
),
|
|
];
|
|
assert.equal(entries.length, 3);
|
|
for (const entry of entries) assert.match(entry[0], /\bhidden(?:[\s=>])/);
|
|
assertPageLinksHaveTrailingSlash(html);
|
|
});
|
|
|
|
test("homepage stays available when upstream ad and content lists are null or malformed", async () => {
|
|
const saved = homePayload;
|
|
try {
|
|
homePayload = { ...saved, advitise: { nineGridList: null } };
|
|
let response = await fetch(base, { redirect: "manual" });
|
|
let html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.ok(html.includes("后台配置标题"));
|
|
assert.ok(html.includes('href="/detail/23456/"'));
|
|
for (const value of [null, {}, "bad"]) {
|
|
homePayload = {
|
|
homeBannerData: value,
|
|
homeConfigData: value,
|
|
announcements: value,
|
|
advitise: { nineGridList: value },
|
|
};
|
|
response = await fetch(base, { redirect: "manual" });
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal(
|
|
(html.match(/<h1\b/g) || []).length,
|
|
1,
|
|
"the homepage keeps its title without banners",
|
|
);
|
|
assert.ok(html.includes('id="home-title"'));
|
|
assert.ok(html.includes("暂无推荐剧集"));
|
|
assert.ok(html.includes("暂时没有内容"));
|
|
}
|
|
homePayload = {
|
|
homeBannerData: [null, fixture],
|
|
homeConfigData: [null, ...saved.homeConfigData],
|
|
announcements: [null, { title: "正常公告" }],
|
|
advitise: { nineGridList: [null] },
|
|
};
|
|
response = await fetch(base);
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.ok(html.includes("后台配置标题"));
|
|
assert.ok(html.includes("正常公告"));
|
|
} finally {
|
|
homePayload = saved;
|
|
}
|
|
});
|
|
|
|
test("/navigation renders direct download links without a hover QR panel", async () => {
|
|
const before = calls.length;
|
|
const response = await fetch(base + "/navigation/", { redirect: "manual" });
|
|
const html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.ok(html.includes("<title>官方入口 - 黄果短剧</title>"));
|
|
assert.ok(
|
|
html.includes('<div id="entry-navigation" class="navigation-page">'),
|
|
);
|
|
assert.ok(!html.includes('<dialog id="entry-navigation"'));
|
|
assert.ok(html.includes("黄果短剧 · 线路一"));
|
|
assert.ok(html.includes("黄果短剧 · 线路二"));
|
|
assert.ok(!html.includes("黄果短剧 · 线路三"));
|
|
assert.ok(html.includes('href="https://example.com/entry-one"'));
|
|
assert.ok(html.includes('href="https://example.com/entry-two"'));
|
|
assert.ok(!html.includes('href="javascript:'));
|
|
assert.ok(html.includes('href="https://t.me/jbd125"'));
|
|
assert.ok(!html.includes('href="https://example.com/contact"'));
|
|
assert.ok(!html.includes('href="https://example.com/group"'));
|
|
assert.ok(html.includes('href="/home-road/"'));
|
|
assert.ok(!html.includes('href="/apps/"'));
|
|
assert.ok(!html.includes("福利应用"));
|
|
assert.match(
|
|
html,
|
|
/<a class="entry-download" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/,
|
|
);
|
|
assert.match(
|
|
html,
|
|
/<a class="entry-channel" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/,
|
|
);
|
|
assert.ok(!html.includes('src="data:image/png;base64,'));
|
|
assert.ok(!html.includes('id="site-sidebar"'));
|
|
assert.ok(!html.includes("/scripts/site-runtime.js"));
|
|
assert.ok(!html.includes('id="startup-age"'));
|
|
assert.ok(!html.includes("entry-navigation-pending"));
|
|
assertPageLinksHaveTrailingSlash(html);
|
|
// jha-1048: SSR never guests; client GuestAuthBootstrap owns auth/guest.
|
|
assert.deepEqual(
|
|
calls.slice(before).map((call) => call.path),
|
|
["/api/app/config/info"],
|
|
);
|
|
assert.ok(
|
|
!calls.slice(before).some((call) => call.path === "/api/app/auth/guest"),
|
|
);
|
|
assert.equal(
|
|
(
|
|
await fetch(base + "/navigation/", {
|
|
redirect: "manual",
|
|
headers: { "Cache-Control": "no-cache" },
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
});
|
|
|
|
test("/navigation renders configured identity, routes, permanent entry and channels in its initial HTML", async () => {
|
|
try {
|
|
seoConfig = {
|
|
seoNavInfo: {
|
|
dhSiteName: "动态导航站",
|
|
dhDescription: "后端配置的导航介绍",
|
|
},
|
|
config: {
|
|
telegram: "@configured_contact",
|
|
tgGroup: "@configured_group",
|
|
twitterAddr: "https://x.com/configured_91",
|
|
foreverUrlAddr: "https://permanent.drama.test/entry",
|
|
},
|
|
navPageList: [
|
|
{ line: 4, url: "https://four.drama.test" },
|
|
{ line: 2, url: "https://two.drama.test" },
|
|
],
|
|
};
|
|
const start = calls.length,
|
|
response = await fetch(base + "/navigation/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.ok(html.includes("动态导航站官方入口"));
|
|
assert.ok(html.includes("后端配置的导航介绍"));
|
|
assert.ok(html.includes("动态导航站 · 线路二"));
|
|
assert.ok(html.includes("动态导航站 · 线路四"));
|
|
assert.ok(!html.includes("动态导航站 · 线路一"));
|
|
assert.ok(
|
|
html.indexOf("动态导航站 · 线路二") < html.indexOf("动态导航站 · 线路四"),
|
|
);
|
|
assert.match(
|
|
html,
|
|
/<a class="entry-permanent" href="https:\/\/permanent.drama.test\/entry" rel="sponsored nofollow noopener noreferrer"/,
|
|
);
|
|
for (const url of [
|
|
"https://t.me/configured_contact",
|
|
"https://t.me/configured_group",
|
|
"https://x.com/configured_91",
|
|
])
|
|
assert.ok(!html.includes(`href="${url}"`), url);
|
|
assert.ok(html.includes('href="https://t.me/jbd125"'));
|
|
assert.ok(!html.includes("huangguoshortdrama"));
|
|
assert.ok(!html.includes("huangguodrama"));
|
|
assert.deepEqual(
|
|
calls.slice(start).map((call) => call.path),
|
|
["/api/app/config/info"],
|
|
);
|
|
assert.ok(
|
|
!calls.slice(start).some((call) => call.path === "/api/app/auth/guest"),
|
|
);
|
|
} finally {
|
|
seoConfig = {};
|
|
}
|
|
});
|
|
|
|
test("/navigation omits unconfigured routes and contacts instead of showing homepage links or demo accounts", async () => {
|
|
const saved = downloadAddress;
|
|
try {
|
|
downloadAddress = "";
|
|
seoConfig = {
|
|
navPageList: null,
|
|
config: {
|
|
foreverUrlAddr: "",
|
|
telegram: "",
|
|
telegramAdd: "",
|
|
tgGroup: "",
|
|
twitterAddr: "",
|
|
},
|
|
};
|
|
const response = await fetch(base + "/navigation/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.ok(html.includes("暂未配置访问线路"));
|
|
for (const className of [
|
|
"entry-route",
|
|
"entry-permanent",
|
|
"entry-download",
|
|
])
|
|
assert.ok(!html.includes(`class="${className}"`));
|
|
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));
|
|
for (const label of ["官方TG群", "官方推特"])
|
|
assert.ok(!html.includes(label));
|
|
assert.ok(html.includes('href="/home-road/"'));
|
|
assert.ok(!html.includes('href="/apps/"'));
|
|
// business contact and return-home remain without a download address
|
|
assert.ok(html.includes("grid-template-columns:repeat(2, minmax(0, 1fr))"));
|
|
} finally {
|
|
seoConfig = {};
|
|
downloadAddress = saved;
|
|
}
|
|
});
|
|
|
|
test("sidebar and footer always render the single business contact and never configured or unsafe channels", async () => {
|
|
try {
|
|
for (const configured of [true, false]) {
|
|
seoConfig = {
|
|
config: configured
|
|
? {
|
|
telegram: "@jgdj_contact",
|
|
tgGroup: "@jgdj_group",
|
|
twitterAddr: "https://x.com/jgdj_channel",
|
|
}
|
|
: {
|
|
telegram: "",
|
|
telegramAdd: "",
|
|
tgGroup: "javascript:alert(1)",
|
|
twitterAddr: "data:text/html,bad",
|
|
},
|
|
};
|
|
const response = await fetch(base + "/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
for (const className of ["sidebar-social", "site-footer-social"]) {
|
|
const channels = html.match(
|
|
new RegExp(`<div class="${className}"[^>]*>([\\s\\S]*?)</div>`),
|
|
)?.[1];
|
|
assert.notEqual(channels, undefined, className);
|
|
assert.equal((channels.match(/<a\b/g) || []).length, 1, className);
|
|
assert.ok(channels.includes('href="https://t.me/jbd125"'), className);
|
|
for (const url of ["jgdj_contact", "jgdj_group", "x.com/jgdj_channel", "javascript:", "data:text"])
|
|
assert.ok(!channels.includes(url), className + " " + url);
|
|
}
|
|
assert.doesNotMatch(html, /huangguoAI_2026|huangguoAITV/);
|
|
}
|
|
} finally {
|
|
seoConfig = {};
|
|
}
|
|
});
|
|
|
|
test("/navigation omits every download entry when no valid App URL is configured", async () => {
|
|
const saved = downloadAddress;
|
|
try {
|
|
for (const value of ["", "javascript:alert(1)"]) {
|
|
downloadAddress = value;
|
|
const response = await fetch(base + "/navigation/", {
|
|
redirect: "manual",
|
|
});
|
|
const html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.ok(!html.includes('class="entry-download"'));
|
|
assert.ok(!html.includes('data-app-download=""'));
|
|
assert.ok(!html.includes('id="app-download-dialog"'));
|
|
assert.ok(html.includes("官方渠道"));
|
|
assert.ok(!html.includes('href="/apps/"'));
|
|
}
|
|
} finally {
|
|
downloadAddress = saved;
|
|
}
|
|
});
|
|
|
|
test("all clean page URLs support direct access and refresh", async () => {
|
|
const paths = [
|
|
"/shorts/",
|
|
"/topics/",
|
|
"/rank/",
|
|
"/home-road/",
|
|
"/topics/3/",
|
|
"/theme/8/",
|
|
"/tag/9/",
|
|
"/config/31/",
|
|
"/detail/23456/",
|
|
"/play/23456/90001/",
|
|
"/search/" + encodeURIComponent("都市 / 爱情?#%") + "/",
|
|
"/info/about-us/",
|
|
"/info/user-agreement/",
|
|
"/info/privacy-policy/",
|
|
"/info/help-center/",
|
|
"/info/2257/",
|
|
"/info/dmca/",
|
|
];
|
|
for (const path of [
|
|
"/apps/",
|
|
"/member/center/",
|
|
"/member/favorites/",
|
|
"/member/history/",
|
|
"/member/likes/",
|
|
"/member/purchased/",
|
|
"/member/settings/",
|
|
"/info/deposit/",
|
|
"/info/deposit/vip/",
|
|
"/info/user-center/",
|
|
"/info/settings/",
|
|
"/info/feedback/",
|
|
]) {
|
|
const removedPage = await fetch(new URL(path, base + "/"), { redirect: "manual" });
|
|
assert.equal(removedPage.status, 404, path + " was removed");
|
|
await removedPage.arrayBuffer();
|
|
}
|
|
for (const path of paths) {
|
|
const response = await fetch(new URL(path, base + "/"), {
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
assert.equal(
|
|
new URL(response.url).pathname,
|
|
new URL(path, base).pathname,
|
|
path,
|
|
);
|
|
const html = await response.text();
|
|
assert.ok(html.includes('id="channel-page"'), path);
|
|
assert.ok(html.includes("/scripts/site-runtime.js"), path);
|
|
assert.equal(
|
|
(html.match(/<h1\b/g) || []).length,
|
|
1,
|
|
path + " must retain its own h1 without inactive template headings",
|
|
);
|
|
assert.ok(!html.includes('id="entry-navigation"'), path);
|
|
assert.ok(!html.includes("entry-navigation-pending"), path);
|
|
assertPageLinksHaveTrailingSlash(html);
|
|
const withoutSlash = await fetch(base + path.slice(0, -1), {
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(withoutSlash.status, 308, path);
|
|
assert.equal(
|
|
new URL(withoutSlash.headers.get("location"), base).href,
|
|
base + path,
|
|
path,
|
|
);
|
|
}
|
|
const refreshed = await fetch(base + "/shorts/", {
|
|
headers: { "Cache-Control": "no-cache" },
|
|
});
|
|
assert.equal(refreshed.status, 200);
|
|
});
|
|
|
|
test("page slash redirects preserve encoded paths and queries without requesting upstream content", async () => {
|
|
const keyword = encodeURIComponent("都市 / 爱情?#% example.com");
|
|
const paths = [
|
|
"/navigation",
|
|
"/search",
|
|
"/search/" + keyword,
|
|
"/search/" + keyword + "/page/2",
|
|
"/rank/hot",
|
|
"/rank/new",
|
|
"/rank/follow",
|
|
"/info/about-us",
|
|
"/shorts/page/2",
|
|
];
|
|
const query = "?channel=a%2Fb+1&sort=2&source=x%252Fy";
|
|
const before = calls.length;
|
|
for (const path of paths) {
|
|
for (const method of ["GET", "HEAD"]) {
|
|
const response = await fetch(base + path + query, {
|
|
method,
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(response.status, 308, path);
|
|
assert.equal(
|
|
new URL(response.headers.get("location"), base).href,
|
|
base + path + "/" + query,
|
|
path,
|
|
);
|
|
}
|
|
}
|
|
assert.equal(
|
|
calls.length,
|
|
before,
|
|
"redirects must run before page/API data fetching",
|
|
);
|
|
for (const path of [
|
|
"/favicon.png",
|
|
"/placeholder.png",
|
|
"/scripts/site-runtime.js",
|
|
]) {
|
|
const response = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
await response.arrayBuffer();
|
|
}
|
|
const missing = await fetch(base + "/unknown-page", { redirect: "manual" });
|
|
assert.equal(missing.status, 404);
|
|
assert.equal(missing.headers.get("location"), null);
|
|
});
|
|
|
|
test("empty and paginated search entries retain native paths and keyword-specific metadata", async () => {
|
|
searchPayload = { list: [fixture], totalCount: 48, pageSize: 24 };
|
|
try {
|
|
for (const path of [
|
|
"/search/",
|
|
"/search/" + encodeURIComponent("都市 / 爱情") + "/",
|
|
"/search/" + encodeURIComponent("都市 / 爱情") + "/page/2/",
|
|
]) {
|
|
const response = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
const html = await response.text();
|
|
assert.ok(
|
|
html.includes("搜索短剧") || html.includes("都市 / 爱情 · 搜索结果"),
|
|
);
|
|
if (path.includes("/page/2/")) assert.match(html, /第\s*2\s*页/);
|
|
}
|
|
} finally {
|
|
searchPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("fixed information pages retain local article markup and only load shared head configuration in SSR", async () => {
|
|
for (const [key, title, marker] of [
|
|
["about-us", "关于我们", "品牌介绍"],
|
|
["user-agreement", "用户协议", "关于《使用条款》"],
|
|
["privacy-policy", "隐私政策", "八、问题和建议"],
|
|
["help-center", "常见问题", "<details open>"],
|
|
["2257", "2257 合规声明", "资料留存"],
|
|
["dmca", "DMCA 版权声明", "版权通知及反通知请通过<a href=\"https://t.me/jbd125\""],
|
|
]) {
|
|
const count = calls.length,
|
|
response = await fetch(base + `/info/${key}/`, { redirect: "manual" });
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
const html = await response.text();
|
|
assert.ok(html.includes(`<title>${title} - 黄果短剧</title>`));
|
|
assert.ok(html.includes(marker), key);
|
|
const main = html.match(/<main\b[^>]*\bid="info-page"[^>]*>/)?.[0];
|
|
assert.ok(main);
|
|
assert.ok(main.includes(`data-info="${key}"`));
|
|
assert.ok(!/\shidden(?:[=\s>])/.test(main));
|
|
assert.deepEqual(
|
|
calls
|
|
.slice(count)
|
|
.map((call) => call.path)
|
|
.sort(),
|
|
[
|
|
"/api/app/config/info",
|
|
"/api/app/config/footer/info",
|
|
"/api/app/navigation/header",
|
|
"/api/app/translation/category/list",
|
|
].sort(),
|
|
"fixed page SSR must not fetch homepage, guest, or rich text",
|
|
);
|
|
assert.ok(
|
|
!calls.slice(count).some((call) => call.path === "/api/app/auth/guest"),
|
|
);
|
|
assert.ok(html.includes('class="home-content" hidden=""'));
|
|
}
|
|
});
|
|
|
|
test("footer navigation comes from footer info and opens the matching rich text page", async () => {
|
|
const previousFooter = footerPayload;
|
|
const previousRich = richTextPayload;
|
|
try {
|
|
footerPayload = {
|
|
footerInfo: {
|
|
brand: { title: "接口品牌", content: "接口页脚" },
|
|
token: "private-footer-token",
|
|
navList: [
|
|
{
|
|
id: 1,
|
|
name: "规则",
|
|
urlValue: "/ignored/",
|
|
translationList: [
|
|
{ id: 2, name: "关于我们", urlValue: "/richetext/关于我们" },
|
|
{ id: 3, name: "外链", urlValue: "https://example.com/rules" },
|
|
{ id: 4, name: "脚本", urlValue: "javascript:alert(1)" },
|
|
],
|
|
},
|
|
{ id: 5, name: "用户协议", urlValue: "用户协议" },
|
|
],
|
|
},
|
|
};
|
|
richTextPayload = [
|
|
{
|
|
id: 7,
|
|
title: "关于我们",
|
|
desc: "企业介绍",
|
|
content: "<h2>品牌正文</h2><p>富文本段落</p>",
|
|
token: "private-richtext-token",
|
|
},
|
|
];
|
|
const home = await (await fetch(base + "/")).text();
|
|
const links = home.match(
|
|
/<nav class="site-footer-links"[^>]*>[\s\S]*?<\/nav>/,
|
|
)?.[0];
|
|
assert.ok(links);
|
|
assert.ok(home.includes("接口页脚"));
|
|
assert.match(home, /<strong class="site-footer-title">接口品牌<i/);
|
|
assert.match(home, /© \d{4} 黄果短剧 All rights reserved\./);
|
|
assert.ok(!home.match(/<strong class="site-footer-title">[^<]*黄果短剧/));
|
|
assert.ok(links.includes('href="/richtext/%E5%85%B3%E4%BA%8E%E6%88%91%E4%BB%AC/"'));
|
|
assert.ok(links.includes('href="/richtext/%E7%94%A8%E6%88%B7%E5%8D%8F%E8%AE%AE/"'));
|
|
assert.ok(links.includes('href="https://example.com/rules"'));
|
|
assert.ok(links.includes('target="_blank"'));
|
|
assert.ok(!links.includes("javascript:"));
|
|
assert.ok(!links.includes('href="/ignored/"'));
|
|
assert.ok(!links.includes(">规则<"));
|
|
assert.ok(!home.includes("private-footer-token"));
|
|
const start = calls.length;
|
|
const response = await fetch(base + "/richtext/" + encodeURIComponent("关于我们") + "/", {
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text();
|
|
const main = currentMain(html, "info-page");
|
|
assert.ok(main.includes('data-info="richtext"'));
|
|
assert.ok(main.includes("企业介绍"));
|
|
assert.ok(main.includes("品牌正文"));
|
|
assert.ok(main.includes("富文本段落"));
|
|
assert.ok(!html.includes("private-richtext-token"));
|
|
assert.match(html, /<link rel="canonical" href="https:\/\/default\.drama\.test\/richtext\/%E5%85%B3%E4%BA%8E%E6%88%91%E4%BB%AC\/"/);
|
|
assert.ok(
|
|
calls.slice(start).some((call) => {
|
|
return (
|
|
call.path === "/api/app/config/richtext/list" &&
|
|
new URLSearchParams(call.search).get("titles") === "关于我们"
|
|
);
|
|
}),
|
|
);
|
|
const alias = await (
|
|
await fetch(base + "/richetext/" + encodeURIComponent("关于我们") + "/")
|
|
).text();
|
|
assert.ok(alias.includes("品牌正文"));
|
|
assert.match(alias, /<link rel="canonical" href="https:\/\/default\.drama\.test\/richtext\/%E5%85%B3%E4%BA%8E%E6%88%91%E4%BB%AC\/"/);
|
|
const missing = await (
|
|
await fetch(base + "/richtext/" + encodeURIComponent("没有这篇") + "/")
|
|
).text();
|
|
assert.ok(currentMain(missing, "info-page").includes("这篇内容暂时没有发布。"));
|
|
assert.match(missing, /<meta name="robots" content="noindex,\s*follow"/);
|
|
} finally {
|
|
footerPayload = previousFooter;
|
|
richTextPayload = previousRich;
|
|
}
|
|
});
|
|
|
|
test("unknown pages and malformed detail paths return a visible 404", async () => {
|
|
for (const path of [
|
|
"/unknown-page",
|
|
"/shorts/extra/",
|
|
"/play/23456/",
|
|
"/detail/not-a-number/",
|
|
"/detail/0/",
|
|
"/detail/9007199254740992/",
|
|
"/play/23456/0/",
|
|
"/member/unknown/",
|
|
"/info/unknown/",
|
|
"/richtext/",
|
|
"/richtext/a/b/",
|
|
"/assets/missing.png",
|
|
"/rank/unknown/",
|
|
"/rank/new/extra/",
|
|
]) {
|
|
const response = await fetch(base + path);
|
|
const html = await response.text();
|
|
assert.equal(response.status, 404, path);
|
|
assert.ok(html.includes("route-not-found"), path);
|
|
assert.ok(html.includes("返回首页"), path);
|
|
}
|
|
});
|
|
|
|
test("ranking slash URLs render their titles and bare URLs redirect to the same ranking", async () => {
|
|
for (const [slug, title] of [
|
|
["hot", "热播榜"],
|
|
["new", "新剧榜"],
|
|
["follow", "追剧榜"],
|
|
]) {
|
|
for (const suffix of ["/", ""]) {
|
|
const path = `/rank/${slug}${suffix}`;
|
|
const response = await fetch(base + path, {
|
|
redirect: "manual",
|
|
headers: { "Cache-Control": "no-cache" },
|
|
});
|
|
if (!suffix) {
|
|
assert.equal(response.status, 308, path);
|
|
assert.equal(
|
|
new URL(response.headers.get("location"), base).href,
|
|
base + path + "/",
|
|
path,
|
|
);
|
|
continue;
|
|
}
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
const html = await response.text();
|
|
assert.ok(html.includes(`<title>${title} - 黄果短剧</title>`), path);
|
|
assert.ok(html.includes('href="/rank/hot/"'));
|
|
assertPageLinksHaveTrailingSlash(html);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("SSR emits configured TDK, site_name, canonical and bounded pagination links inside head for browser and crawler user agents", async () => {
|
|
try {
|
|
seoConfig = {
|
|
config: {
|
|
foreverUrlAddr: "https://canonical.drama.test/entry/?channel=abc",
|
|
},
|
|
seoInfo: JSON.stringify({
|
|
title: "后台首页标题",
|
|
description: "后台首页描述",
|
|
keyworld: "首页词,短剧",
|
|
webSiteName: "91剧场",
|
|
}),
|
|
};
|
|
categories = [
|
|
{
|
|
urlValue: "/playlet/",
|
|
seoInfo: {
|
|
title: "短剧导航标题",
|
|
description: "短剧导航描述",
|
|
keyworld: ["导航关键词"],
|
|
},
|
|
},
|
|
];
|
|
listPayload = {
|
|
list: [fixture],
|
|
totalCount: 72,
|
|
seoInfo: {
|
|
title: "列表接口标题",
|
|
description: "列表接口描述",
|
|
keywords: ["接口关键词"],
|
|
},
|
|
};
|
|
const headOf = (html) => {
|
|
const match = html.match(/<head>([\s\S]*?)<\/head>/);
|
|
assert.ok(match);
|
|
return match[1];
|
|
};
|
|
const start = calls.length;
|
|
const home = await (await fetch(base)).text(),
|
|
head = headOf(home);
|
|
assert.match(head, /<title>后台首页标题 - 91剧场<\/title>/);
|
|
assert.match(head, /<meta name="description" content="后台首页描述"/);
|
|
assert.match(head, /<meta name="keywords" content="首页词,短剧"/);
|
|
assert.match(head, /<meta property="og:site_name" content="91剧场"/);
|
|
assert.match(
|
|
head,
|
|
/<link rel="canonical" href="https:\/\/canonical.drama.test\/"/,
|
|
);
|
|
assert.match(
|
|
head,
|
|
/<meta property="og:image" content="https:\/\/canonical.drama.test\/og-default.jpg"/,
|
|
);
|
|
assert.match(
|
|
head,
|
|
/<meta name="twitter:card" content="summary_large_image"/,
|
|
);
|
|
assert.equal(
|
|
calls.slice(start).filter((call) => call.path === "/api/app/playlet/home")
|
|
.length,
|
|
1,
|
|
"SSR and metadata share one homepage request",
|
|
);
|
|
assert.equal(
|
|
calls.slice(start).filter((call) => call.path === "/api/app/config/info")
|
|
.length,
|
|
1,
|
|
"SSR and metadata share one configuration request",
|
|
);
|
|
assert.match(head,/google-site-verification" content="QJFFfKYHJsNfa-lGTdGjsCJc4rytPw-4wsTty-BM6iU"/);
|
|
assert.ok(!/google-analytics|mc\.yandex/.test(head));
|
|
assert.match(home,/googletagmanager\.com\/gtm\.js\?id=/);
|
|
assert.match(home,/['"]GTM-TXLP67SX['"]/);
|
|
assert.match(home,/googletagmanager\.com\/ns\.html\?id=GTM-TXLP67SX/);
|
|
for (const agent of [
|
|
"Mozilla/5.0 Chrome/130",
|
|
"Googlebot",
|
|
"facebookexternalhit",
|
|
]) {
|
|
for (const [path, page] of [
|
|
["/shorts/", 1],
|
|
["/shorts/page/2/", 2],
|
|
["/shorts/page/3/", 3],
|
|
]) {
|
|
const response = await fetch(base + path + "?channel=campaign", {
|
|
redirect: "manual",
|
|
headers: { "User-Agent": agent },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
const head = headOf(await response.text());
|
|
const canonical = `https://canonical.drama.test/shorts/${page > 1 ? `page/${page}/` : ""}`;
|
|
assert.ok(head.includes(`<link rel="canonical" href="${canonical}"`));
|
|
assert.ok(
|
|
head.includes(`<meta property="og:url" content="${canonical}"`),
|
|
);
|
|
assert.ok(
|
|
head.includes(
|
|
`<title>短剧导航标题${page > 1 ? " - 第" + page + "页" : ""}</title>`,
|
|
),
|
|
);
|
|
assert.ok(
|
|
head.includes(
|
|
`<meta name="keywords" content="导航关键词${page > 1 ? ",第" + page + "页" : ""}"`,
|
|
),
|
|
);
|
|
assert.equal(head.includes('<link rel="prev"'), page > 1);
|
|
assert.equal(head.includes('<link rel="next"'), page < 3);
|
|
if (page === 2)
|
|
assert.ok(
|
|
head.includes(
|
|
'rel="prev" href="https://canonical.drama.test/shorts/?channel=campaign"',
|
|
),
|
|
);
|
|
assert.equal((head.match(/rel="canonical"/g) || []).length, 1);
|
|
}
|
|
}
|
|
for (const path of [
|
|
"/search/爱情/",
|
|
"/info/deposit/",
|
|
"/member/history/",
|
|
"/navigation/",
|
|
"/shorts/?sort=2",
|
|
]) {
|
|
const head = headOf(await (await fetch(base + path)).text());
|
|
assert.ok(!head.includes('rel="canonical"'), path);
|
|
assert.match(head, /<meta name="robots" content="noindex/);
|
|
}
|
|
const overflowResponse = await fetch(base + "/shorts/page/4/");
|
|
assert.equal(overflowResponse.status, 404);
|
|
const overflow = headOf(await overflowResponse.text());
|
|
assert.match(overflow, /<meta name="robots" content="noindex/);
|
|
assert.ok(!overflow.includes('rel="canonical"'));
|
|
assert.ok(!overflow.includes('rel="next"'));
|
|
} finally {
|
|
seoConfig = {};
|
|
categories = [];
|
|
listPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("detail SEO reads protected upstream content without exposing credentials and keeps registered sessions intact", async () => {
|
|
try {
|
|
requireSeoAuth = true;
|
|
seoConfig = {
|
|
seoInfo: { webSiteName: "91剧场" },
|
|
config: { foreverUrlAddr: "https://canonical.drama.test/" },
|
|
};
|
|
detailSeo = {
|
|
seoInfo: {
|
|
title: "剧集独立标题",
|
|
description: "真实剧集描述",
|
|
keywords: "剧集词",
|
|
},
|
|
};
|
|
const start = calls.length;
|
|
// Protected SEO reads use the client cookie token (jha-1048); SSR must not invent a guest.
|
|
const response = await fetch(base + "/detail/23456/", {
|
|
headers: {
|
|
"X-Real-IP": "203.0.113.8",
|
|
Cookie: "drama_bearer=test-session-secret",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text(),
|
|
head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
|
|
assert.ok(head.includes("<title>剧集独立标题 - 91剧场</title>"));
|
|
assert.ok(head.includes('content="真实剧集描述"'));
|
|
assert.ok(
|
|
head.includes(
|
|
'rel="canonical" href="https://canonical.drama.test/detail/23456/"',
|
|
),
|
|
);
|
|
assert.equal(
|
|
calls.slice(start).filter((call) => call.path === "/api/app/auth/guest")
|
|
.length,
|
|
0,
|
|
);
|
|
assert.ok(
|
|
calls
|
|
.slice(start)
|
|
.some(
|
|
(call) =>
|
|
call.path === "/api/app/auth/getInfo" &&
|
|
call.ip === "203.0.113.8",
|
|
),
|
|
);
|
|
assert.ok(!html.includes("test-session-secret"));
|
|
assert.ok(!html.includes("drama_session"));
|
|
const registeredStart = calls.length;
|
|
const registered = await fetch(base + "/detail/23456/", {
|
|
headers: { Cookie: "drama_session=expired; drama_registered=1" },
|
|
});
|
|
assert.equal(registered.status, 200);
|
|
const signedOut = await registered.text();
|
|
assert.ok(signedOut.includes("内容暂不可用"));
|
|
assert.ok(!signedOut.includes("请先登录账号"));
|
|
assert.ok(!signedOut.includes("data-auth-mode"));
|
|
assert.ok(signedOut.includes("noindex"));
|
|
assert.ok(!signedOut.includes('rel="canonical"'));
|
|
assert.equal(
|
|
calls
|
|
.slice(registeredStart)
|
|
.filter((call) => call.path === "/api/app/auth/guest").length,
|
|
0,
|
|
"registered identity is never replaced with guest",
|
|
);
|
|
} finally {
|
|
requireSeoAuth = false;
|
|
seoConfig = {};
|
|
detailSeo = undefined;
|
|
}
|
|
});
|
|
|
|
test("permanent canonical and site identity survive config errors, timeouts, missing fields and a server restart", async () => {
|
|
const domain = "https://stable.drama.test",
|
|
brand = "稳定站点";
|
|
const identity = async () => {
|
|
const response = await fetch(base + "/shorts/page/2/?channel=campaign", {
|
|
headers: {
|
|
"X-Forwarded-Host": "alternate.entry.test",
|
|
"X-Forwarded-Proto": "http",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text(),
|
|
head = html.match(/<head>([\s\S]*?)<\/head>/)[1];
|
|
assert.ok(head.includes(`rel="canonical" href="${domain}/shorts/page/2/"`));
|
|
assert.ok(
|
|
head.includes(`property="og:url" content="${domain}/shorts/page/2/"`),
|
|
);
|
|
assert.ok(head.includes(`property="og:site_name" content="${brand}"`));
|
|
assert.ok(
|
|
head.includes(`rel="prev" href="${domain}/shorts/?channel=campaign"`),
|
|
);
|
|
assert.ok(
|
|
head.includes(
|
|
`rel="next" href="${domain}/shorts/page/3/?channel=campaign"`,
|
|
),
|
|
);
|
|
const graph = JSON.parse(
|
|
html.match(
|
|
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/,
|
|
)[1],
|
|
)["@graph"];
|
|
const organization = graph.find((item) => item["@type"] === "Organization"),
|
|
website = graph.find((item) => item["@type"] === "WebSite");
|
|
assert.equal(organization.url, domain + "/");
|
|
assert.equal(organization.name, brand);
|
|
assert.equal(website.name, brand);
|
|
assert.ok(!head.includes("alternate.entry.test"));
|
|
assert.ok(!head.includes("noindex"));
|
|
};
|
|
try {
|
|
seoConfig = {
|
|
config: { foreverUrlAddr: domain },
|
|
seoInfo: { webSiteName: brand },
|
|
};
|
|
listPayload = { list: [fixture], totalCount: 72 };
|
|
await identity();
|
|
for (const failure of ["http", "business", "malformed", "timeout"]) {
|
|
configFailure = failure;
|
|
await identity();
|
|
}
|
|
configFailure = "";
|
|
seoConfig = {
|
|
config: { foreverUrlAddr: "" },
|
|
newAddressInfo: { foreverUrlAddr: "" },
|
|
seoInfo: { webSiteName: "不可替换的错误品牌" },
|
|
};
|
|
await identity();
|
|
configFailure = "http";
|
|
await stopNext();
|
|
await startNext();
|
|
await identity();
|
|
configFailure = "";
|
|
seoConfig = {
|
|
config: { foreverUrlAddr: "https://updated.drama.test" },
|
|
seoInfo: { webSiteName: "更新站点" },
|
|
};
|
|
const html = await (await fetch(base + "/shorts/")).text();
|
|
assert.ok(
|
|
html.includes(
|
|
'rel="canonical" href="https://updated.drama.test/shorts/"',
|
|
),
|
|
);
|
|
assert.ok(html.includes('property="og:site_name" content="更新站点"'));
|
|
} finally {
|
|
configFailure = "";
|
|
seoConfig = {};
|
|
categories = [];
|
|
listPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("SSR JSON-LD matches each canonical route, list page, content entity and visible FAQ without credentials or executable injection", async () => {
|
|
const savedHome = homePayload;
|
|
const graphOf = (html) => {
|
|
const scripts = [
|
|
...html.matchAll(
|
|
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/g,
|
|
),
|
|
];
|
|
assert.equal(
|
|
scripts.length,
|
|
1,
|
|
"one native JSON-LD script in the initial HTML",
|
|
);
|
|
const graph = JSON.parse(scripts[0][1]);
|
|
assert.equal(graph["@context"], "https://schema.org");
|
|
return graph["@graph"];
|
|
};
|
|
try {
|
|
const origin = "https://schema.drama.test";
|
|
seoConfig = {
|
|
seoInfo: { webSiteName: "91剧场" },
|
|
config: { foreverUrlAddr: origin },
|
|
};
|
|
listPayload = {
|
|
list: [
|
|
{ ...fixture, id: 7 },
|
|
{ ...fixture, id: 8 },
|
|
],
|
|
totalCount: 72,
|
|
name: "当前接口栏目",
|
|
};
|
|
detailSeo = {
|
|
onlineAt: "2025-10-18 10:30:00",
|
|
mediaUpdatedAt: "2025-10-19 09:00:00",
|
|
title: "剧名 </script><script>alert(1)</script>",
|
|
desc: "真实剧集简介",
|
|
};
|
|
chapterPayload = {
|
|
id: 90001,
|
|
playletId: 23456,
|
|
currentEpisode: 3,
|
|
canWatch: true,
|
|
needUnlock: false,
|
|
videoUrl: "https://media.drama.test/episode.m3u8",
|
|
mediaTime: 123,
|
|
};
|
|
videoPayload = {
|
|
...fixture,
|
|
coverImg: fixture.cover,
|
|
createdAt: "2025-10-17",
|
|
video: { url: "https://media.drama.test/movie.mp4", videoTime: 600 },
|
|
};
|
|
let html = await (await fetch(base)).text(),
|
|
graph = graphOf(html);
|
|
assert.ok(
|
|
graph.some(
|
|
(node) => node["@type"] === "Organization" && node.name === "91剧场",
|
|
),
|
|
);
|
|
assert.ok(
|
|
graph.some(
|
|
(node) => node["@type"] === "WebSite" && node.url === origin + "/",
|
|
),
|
|
);
|
|
assert.equal(
|
|
graph.find((node) => node["@type"] === "ItemList").itemListElement[0].url,
|
|
origin + "/detail/23456/",
|
|
);
|
|
for (const [path, type] of [
|
|
["/shorts/page/2/", "CollectionPage"],
|
|
["/rank/new/", "CollectionPage"],
|
|
["/topics/", "CollectionPage"],
|
|
["/topics/7/", "CollectionPage"],
|
|
["/info/about-us/", "AboutPage"],
|
|
["/info/help-center/", "FAQPage"],
|
|
]) {
|
|
html = await (
|
|
await fetch(base + path + "?channel=campaign", {
|
|
headers: { "User-Agent": "Googlebot" },
|
|
})
|
|
).text();
|
|
graph = graphOf(html);
|
|
assert.ok(
|
|
graph.some((node) => node["@type"] === type),
|
|
path,
|
|
);
|
|
assert.equal(
|
|
graph.find((node) => node["@id"] === origin + path + "#webpage").url,
|
|
origin + path,
|
|
);
|
|
if (path === "/shorts/page/2/") {
|
|
const items = graph.find(
|
|
(node) => node["@type"] === "ItemList",
|
|
).itemListElement;
|
|
assert.deepEqual(
|
|
items.map((item) => [item.position, item.url]),
|
|
[
|
|
[25, origin + "/detail/7/"],
|
|
[26, origin + "/detail/8/"],
|
|
],
|
|
);
|
|
}
|
|
if (path === "/info/help-center/")
|
|
assert.equal(
|
|
graph.find((node) => node["@type"] === "FAQPage").mainEntity.length,
|
|
5,
|
|
);
|
|
}
|
|
for (const path of ["/detail/23456/", "/play/23456/90001/"]) {
|
|
html = await (await fetch(base + path)).text();
|
|
graph = graphOf(html);
|
|
assert.ok(!html.includes("<script>alert(1)</script>"));
|
|
assert.ok(!JSON.stringify(graph).includes("test-session-secret"));
|
|
if (path.startsWith("/detail/")) {
|
|
assert.ok(graph.some((node) => node["@type"] === "TVSeries"));
|
|
assert.ok(!graph.some((node) => node["@type"] === "VideoObject"));
|
|
} else {
|
|
const video = graph.find((node) => node["@type"] === "VideoObject");
|
|
assert.ok(video, path);
|
|
assert.ok(video.thumbnailUrl[0]);
|
|
assert.ok(video.uploadDate.includes("+08:00"));
|
|
assert.ok(video.contentUrl.startsWith("https://media.drama.test/"));
|
|
if (path.startsWith("/play/"))
|
|
assert.equal(
|
|
graph.find((node) => node["@type"] === "TVEpisode").episodeNumber,
|
|
3,
|
|
);
|
|
}
|
|
}
|
|
for (const path of [
|
|
"/search/爱情/",
|
|
"/member/center/",
|
|
"/info/deposit/",
|
|
"/navigation/",
|
|
"/apps/",
|
|
"/shorts/?sort=2",
|
|
"/unknown/",
|
|
]) {
|
|
html = await (await fetch(base + path)).text();
|
|
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html), path);
|
|
}
|
|
} finally {
|
|
homePayload = savedHome;
|
|
seoConfig = {};
|
|
listPayload = undefined;
|
|
detailSeo = undefined;
|
|
chapterPayload = undefined;
|
|
videoPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("paginated routes serve slash URLs directly and redirect bare paths with their queries", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
listPayload = accountListPayload = { list: [fixture], totalCount: 48 };
|
|
searchPayload = { list: [fixture], totalCount: 48, pageSize: 24 };
|
|
commentPayload = transactionsPayload = { list: [], totalCount: 18 };
|
|
homePayload = {
|
|
...savedHome,
|
|
homeConfigData: [
|
|
{
|
|
id: 31,
|
|
status: true,
|
|
name: "后台配置标题",
|
|
type: 1,
|
|
list: Array.from({ length: 48 }, (_, i) => ({
|
|
...fixture,
|
|
id: fixture.id + i,
|
|
})),
|
|
},
|
|
],
|
|
};
|
|
const keyword = encodeURIComponent("都市 / page/7/?#%");
|
|
const bases = [
|
|
"/shorts/",
|
|
"/topics/",
|
|
"/topics/7/",
|
|
"/theme/8/",
|
|
"/tag/9/",
|
|
"/config/31/",
|
|
"/rank/",
|
|
"/rank/hot/",
|
|
"/rank/new/",
|
|
"/rank/follow/",
|
|
"/search/" + keyword + "/",
|
|
];
|
|
for (const basePath of bases) {
|
|
for (const suffix of ["page/2/", "page/2"]) {
|
|
const path = basePath + suffix + "?sort=2";
|
|
const response = await fetch(base + path, {
|
|
redirect: "manual",
|
|
headers: { "Cache-Control": "no-cache" },
|
|
});
|
|
if (!suffix.endsWith("/")) {
|
|
assert.equal(response.status, 308, path);
|
|
assert.equal(
|
|
new URL(response.headers.get("location"), base).href,
|
|
base + basePath + suffix + "/?sort=2",
|
|
path,
|
|
);
|
|
continue;
|
|
}
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
assert.equal(new URL(response.url).pathname, basePath + suffix, path);
|
|
const html = await response.text();
|
|
if (response.status === 200) assert.match(html, /第\s*2\s*页/, path);
|
|
assertPageLinksHaveTrailingSlash(html);
|
|
}
|
|
}
|
|
for (const path of [
|
|
"/shorts/page/0/",
|
|
"/shorts/page/01/",
|
|
"/shorts/page/2/page/3/",
|
|
"/apps/page/2/",
|
|
"/home-road/page/2/",
|
|
"/info/about-us/page/2/",
|
|
"/detail/23456/page/2/",
|
|
"/play/23456/90001/page/2/",
|
|
"/member/favorites/page/2/",
|
|
"/info/deposit/page/2/",
|
|
]) {
|
|
const response = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(response.status, 404, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
}
|
|
} finally {
|
|
homePayload = savedHome;
|
|
listPayload =
|
|
accountListPayload =
|
|
searchPayload =
|
|
commentPayload =
|
|
transactionsPayload =
|
|
undefined;
|
|
}
|
|
});
|
|
|
|
test("guest session token stays in HttpOnly cookie and device header is forwarded", async () => {
|
|
const response = await fetch(base + "/api/app/auth/guest", {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{}",
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
const payload = await response.json();
|
|
assert.equal(payload.code, 200);
|
|
assert.equal(payload.data.token, undefined);
|
|
const cookies = response.headers.getSetCookie();
|
|
assert.ok(
|
|
cookies.some(
|
|
(value) =>
|
|
value.startsWith("drama_session=") &&
|
|
value.toLowerCase().includes("httponly") &&
|
|
value.includes("SameSite=lax"),
|
|
),
|
|
);
|
|
assert.ok(
|
|
calls
|
|
.findLast((call) => call.path.endsWith("/auth/guest"))
|
|
.device.includes("SysType=h5_pc;Ver=1.0.0;"),
|
|
);
|
|
});
|
|
|
|
test("browser app API routes preserve sessions and queries through Next", async () => {
|
|
let start = calls.length;
|
|
const guest = await fetch(base + "/api/app/auth/guest", {
|
|
method: "POST",
|
|
headers: { Origin: base, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ devID: "bff-device" }),
|
|
});
|
|
assert.equal(guest.status, 200);
|
|
assert.equal((await guest.json()).data.token, undefined);
|
|
assert.ok(
|
|
guest.headers
|
|
.getSetCookie()
|
|
.some(
|
|
(value) =>
|
|
value.startsWith("drama_session=") && value.includes("HttpOnly"),
|
|
),
|
|
);
|
|
assert.deepEqual(
|
|
calls.slice(start).map((call) => call.path),
|
|
["/api/app/auth/guest"],
|
|
);
|
|
const cookie = guest.headers
|
|
.getSetCookie()
|
|
.map((value) => value.split(";")[0])
|
|
.join("; ");
|
|
const info = await fetch(base + "/api/app/auth/getInfo", {
|
|
headers: { Cookie: cookie },
|
|
});
|
|
assert.equal(info.status, 200);
|
|
assert.equal((await info.json()).data.userId, 77);
|
|
assert.equal(calls.at(-1).auth, "test-session-secret");
|
|
const search = await fetch(
|
|
base + "/api/app/playlet/search?keyword=test&pageNum=2&pageSize=4",
|
|
{ headers: { Cookie: cookie } },
|
|
);
|
|
assert.equal(search.status, 200);
|
|
assert.ok(Array.isArray((await search.json()).data.list));
|
|
assert.equal(calls.at(-1).path, "/api/app/playlet/search");
|
|
assert.equal(new URLSearchParams(calls.at(-1).search).get("pageNum"), "2");
|
|
start = calls.length;
|
|
assert.equal((await fetch(base + "/api/app/not-allowed")).status, 404);
|
|
assert.equal(calls.length, start);
|
|
});
|
|
|
|
test("both API routes preserve native and client UA, SDK identity, IP and JSON request headers", async () => {
|
|
const ua =
|
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP1A) Chrome/124.0 Mobile Safari/537.36";
|
|
const xua =
|
|
"DevID=client-device;DevType=Android;SysType=h5_android;Ver=2.0;BuildID=web;sid=client-sid";
|
|
const fields = {
|
|
sid: "sdk-sid",
|
|
device_id: "sdk-device",
|
|
device: "Android",
|
|
device_fingerprint: "raw",
|
|
fp_version: "3",
|
|
app_version: "1.0.0",
|
|
};
|
|
for (const prefix of ["/api", "/api/app"]) {
|
|
const response = await fetch(base + prefix + "/auth/guest", {
|
|
method: "POST",
|
|
headers: {
|
|
"User-Agent": ua,
|
|
"X-User-Agent": xua,
|
|
"X-Real-IP": "203.0.113.9",
|
|
"X-Forwarded-For": "203.0.113.8, 10.0.0.1",
|
|
"X-SDK-Common": Buffer.from(JSON.stringify(fields)).toString("base64"),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ devID: "business-device" }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal((await response.json()).data.token, undefined);
|
|
const call = calls.at(-1);
|
|
assert.equal(call.ua, ua);
|
|
assert.equal(call.device, xua);
|
|
assert.equal(call.ip, "203.0.113.9");
|
|
assert.equal(call.accept, "application/json");
|
|
assert.equal(call.contentType, "application/json");
|
|
assert.equal(JSON.parse(call.body).devID, "business-device");
|
|
// Only the visitor identity survives; analytics fingerprint fields are dropped.
|
|
assert.deepEqual(JSON.parse(Buffer.from(call.sdk, "base64")), {
|
|
sid: fields.sid,
|
|
device_id: fields.device_id,
|
|
app_version: fields.app_version,
|
|
});
|
|
assert.equal(call.forwarded, undefined);
|
|
assert.equal(call.cookie, undefined);
|
|
}
|
|
});
|
|
|
|
test("public SSR and page session requests use the visitor UA, SID and IP with the configured upstream", async () => {
|
|
const ua =
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15";
|
|
const start = calls.length;
|
|
const response = await fetch(base + "/shorts/", {
|
|
headers: {
|
|
"User-Agent": ua,
|
|
"X-Real-IP": "203.0.113.6",
|
|
Cookie:
|
|
"drama_session=registered-user-a-secret; drama_registered=1; sid=ssr-sid",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
await response.text();
|
|
const forwarded = calls.slice(start);
|
|
assert.ok(forwarded.some((call) => call.path === "/api/app/auth/getInfo"));
|
|
assert.ok(
|
|
forwarded.some((call) => call.path === "/api/app/playlet/operate/list"),
|
|
);
|
|
for (const call of forwarded) {
|
|
assert.equal(call.ua, ua, call.path);
|
|
assert.equal(call.ip, "203.0.113.6", call.path);
|
|
assert.equal(
|
|
call.device,
|
|
"DevType=Apple Mac desktop;SysType=h5_pc;Ver=1.0.0;BuildID=Safari 17.5;device_brand=Apple;device_model=Mac;system_name=macOS;system_version=14.5;sid=ssr-sid",
|
|
call.path,
|
|
);
|
|
if (call.path !== "/api/app/auth/getInfo")
|
|
assert.equal(call.auth, undefined);
|
|
}
|
|
});
|
|
|
|
test("browser POSTs reach upstream through CDN host and protocol changes on both API routes", async () => {
|
|
const port = new URL(base).port;
|
|
const browserOrigin = "https://cdn.drama.example";
|
|
const cases = [
|
|
{ Host: `localhost:${port}`, Origin: `http://localhost:${port}` },
|
|
{ Host: `127.0.0.1:${port}`, Origin: `http://127.0.0.1:${port}` },
|
|
{ Host: "cdn.drama.example", Origin: browserOrigin },
|
|
{
|
|
Host: "next.internal:21030",
|
|
Origin: browserOrigin,
|
|
"X-Forwarded-Host": "cdn.drama.example",
|
|
"X-Forwarded-Proto": "https",
|
|
},
|
|
{ Host: "next.internal:21030", Origin: "null" },
|
|
{
|
|
Host: "next.internal:21030",
|
|
Origin: "https://other.example",
|
|
"Sec-Fetch-Site": "cross-site",
|
|
},
|
|
];
|
|
for (const prefix of ["/api", "/api/app"])
|
|
for (const headers of cases) {
|
|
const count = calls.length;
|
|
// Node fetch replaces a custom Host; use HTTP directly to exercise the actual authority.
|
|
const { status, payload } = await new Promise((resolve, reject) => {
|
|
const request = httpRequest(
|
|
base + prefix + "/auth/guest",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Sec-Fetch-Site": "same-origin",
|
|
"Content-Type": "application/json",
|
|
...headers,
|
|
},
|
|
},
|
|
(response) => {
|
|
let body = "";
|
|
response.on("data", (chunk) => (body += chunk));
|
|
response.on("end", () => {
|
|
try {
|
|
resolve({
|
|
status: response.statusCode,
|
|
payload: JSON.parse(body),
|
|
});
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
request.on("error", reject);
|
|
request.end(
|
|
JSON.stringify({ automatic: true, devID: "origin-test-device" }),
|
|
);
|
|
});
|
|
assert.equal(
|
|
status,
|
|
200,
|
|
prefix + " " + JSON.stringify(headers) + ": " + payload.msg,
|
|
);
|
|
assert.equal(payload.data.userId, 77);
|
|
assert.deepEqual(
|
|
calls.slice(count).map((call) => call.path),
|
|
["/api/app/auth/guest"],
|
|
);
|
|
assert.equal(payload.data.token, undefined);
|
|
}
|
|
});
|
|
|
|
test("browser POSTs preserve upstream authorization errors after proxy forwarding", async () => {
|
|
responseOverrides["auth/guest"] = { status: 403, code: 403 };
|
|
try {
|
|
for (const prefix of ["/api", "/api/app"]) {
|
|
const count = calls.length;
|
|
const response = await fetch(base + prefix + "/auth/guest", {
|
|
method: "POST",
|
|
headers: {
|
|
Origin: "https://cdn.drama.example",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: "{}",
|
|
});
|
|
assert.equal(response.status, 403);
|
|
assert.equal((await response.json()).code, 403);
|
|
assert.deepEqual(
|
|
calls.slice(count).map((call) => call.path),
|
|
["/api/app/auth/guest"],
|
|
);
|
|
}
|
|
} finally {
|
|
delete responseOverrides["auth/guest"];
|
|
}
|
|
});
|
|
|
|
test("public content ignores expired credentials; double-encoded data is normalized", async () => {
|
|
await fetch(base + "/api/app/playlet/home", {
|
|
headers: { Cookie: "drama_session=expired" },
|
|
});
|
|
assert.equal(
|
|
calls.findLast((call) => call.path.endsWith("/playlet/home")).auth,
|
|
undefined,
|
|
);
|
|
const response = await fetch(base + "/api/app/playlet/search?keyword=abc");
|
|
const payload = await response.json();
|
|
assert.equal(payload.data.list[0].id, 23456);
|
|
});
|
|
|
|
test("registered session expiry never retries a write as a guest", async () => {
|
|
const guestCount = calls.filter((call) =>
|
|
call.path.endsWith("/auth/guest"),
|
|
).length;
|
|
const response = await fetch(base + "/api/app/playlet/collect/set", {
|
|
method: "POST",
|
|
headers: {
|
|
Cookie: "drama_session=expired; drama_registered=1",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ id: 23456, isCollect: true }),
|
|
});
|
|
assert.equal(response.status, 401);
|
|
assert.equal((await response.json()).code, 1000);
|
|
assert.equal(
|
|
calls.filter((call) => call.path.endsWith("/auth/guest")).length,
|
|
guestCount,
|
|
);
|
|
assert.equal(response.headers.get("X-Auth-State"), "login-required");
|
|
assert.ok(
|
|
!response.headers
|
|
.getSetCookie()
|
|
.some(
|
|
(value) =>
|
|
value.startsWith("drama_session=;") && value.includes("Max-Age=0"),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("refresh restores an existing account and keeps its token when getInfo omits it", async () => {
|
|
const count = calls.length;
|
|
const response = await fetch(base + "/api/app/auth/guest", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie: "drama_session=test-registered-secret; drama_registered=1",
|
|
},
|
|
body: '{"automatic":true}',
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const data = await response.json();
|
|
assert.equal(data.data.userId, 88);
|
|
assert.equal(data.data.isRegister, true);
|
|
assert.equal(data.data.token, undefined);
|
|
assert.deepEqual(
|
|
calls.slice(count).map((call) => call.path),
|
|
["/api/app/auth/getInfo"],
|
|
);
|
|
// Keep the browser cookie as-is when getInfo echoes the same token, so a later
|
|
// login cookie cannot be overwritten by a stale refresh of the previous identity.
|
|
assert.equal(
|
|
response.headers
|
|
.getSetCookie()
|
|
.some((value) => value.startsWith("drama_session=")),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
response.headers
|
|
.getSetCookie()
|
|
.some((value) => value.startsWith("drama_registered=")),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("an expired registered account is cleared during initialization without becoming a guest", async () => {
|
|
const count = calls.length;
|
|
const response = await fetch(base + "/api/app/auth/guest", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie: "drama_session=expired; drama_registered=1",
|
|
},
|
|
body: '{"automatic":true}',
|
|
});
|
|
assert.equal(response.status, 401);
|
|
assert.equal(response.headers.get("X-Auth-State"), "login-required");
|
|
assert.deepEqual(
|
|
calls.slice(count).map((call) => call.path),
|
|
["/api/app/auth/getInfo"],
|
|
);
|
|
assert.ok(
|
|
response.headers
|
|
.getSetCookie()
|
|
.some(
|
|
(value) =>
|
|
value.startsWith("drama_session=;") && value.includes("Max-Age=0"),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("expired guest requests recover once with the same device, including an explicitly rejected write", async () => {
|
|
for (const [path, method, body] of [
|
|
["auth/getInfo", "GET", undefined],
|
|
[
|
|
"playlet/collect/set",
|
|
"POST",
|
|
JSON.stringify({ id: 23456, isCollect: true }),
|
|
],
|
|
]) {
|
|
const count = calls.length;
|
|
const response = await fetch(base + "/api/app/" + path, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie:
|
|
"drama_session=expired; drama_registered=0; drama_device=device-stable",
|
|
},
|
|
body,
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal((await response.json()).code, 200);
|
|
const requests = calls.slice(count);
|
|
assert.deepEqual(
|
|
requests.map((call) => call.path),
|
|
["/api/app/" + path, "/api/app/auth/guest", "/api/app/" + path],
|
|
);
|
|
assert.equal(requests[1].auth, undefined);
|
|
assert.equal(JSON.parse(requests[1].body).devID, "device-stable");
|
|
assert.equal(requests[2].auth, "test-session-secret");
|
|
}
|
|
const count = calls.length;
|
|
const response = await fetch(base + "/api/app/playlet/collect/set", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Cookie: "drama_session=expired; drama_registered=0",
|
|
},
|
|
body: '{"id":999}',
|
|
});
|
|
assert.equal(response.status, 401);
|
|
assert.equal(calls.length - count, 3);
|
|
});
|
|
|
|
test("an HTTP 401 follows the same guest recovery path", async () => {
|
|
const count = calls.length;
|
|
const response = await fetch(base + "/api/app/auth/getInfo", {
|
|
headers: { Cookie: "drama_session=http-expired; drama_registered=0" },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal((await response.json()).code, 200);
|
|
assert.deepEqual(
|
|
calls.slice(count).map((call) => call.path),
|
|
["/api/app/auth/getInfo", "/api/app/auth/guest", "/api/app/auth/getInfo"],
|
|
);
|
|
});
|
|
|
|
test("proxy rejects unlisted endpoints and wrong methods before calling upstream", async () => {
|
|
const count = calls.length;
|
|
assert.equal(
|
|
(
|
|
await fetch(base + "/api/app/statcenter/sync", {
|
|
method: "POST",
|
|
body: "{}",
|
|
})
|
|
).status,
|
|
404,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await fetch(base + "/api/app/playlet/home", {
|
|
method: "POST",
|
|
body: "{}",
|
|
})
|
|
).status,
|
|
404,
|
|
);
|
|
assert.equal(calls.length, count);
|
|
});
|
|
|
|
test("chapter permissions and identifiers are preserved from the API", async () => {
|
|
const response = await fetch(
|
|
base + "/api/app/playlet/chapter/detail?playletId=23456&chapterId=90001",
|
|
{ headers: { Cookie: "drama_session=test-session-secret" } },
|
|
);
|
|
const payload = await response.json();
|
|
assert.equal(payload.data.id, 90001);
|
|
assert.equal(payload.data.canWatch, false);
|
|
assert.equal(payload.data.singleCoins, 13);
|
|
});
|
|
|
|
test("media proxy preserves signed URLs and HLS content while isolating application credentials", async () => {
|
|
const query = "?token=a%2Fb+1&sig=x%252Fy";
|
|
const ua = "browser-media-ua",
|
|
xua = "DevType=client;SysType=h5_pc;Ver=1.0.0;BuildID=web;sid=media-sid";
|
|
const response = await fetch(base + "/h5/m3u8/folder/movie.m3u8" + query, {
|
|
redirect: "manual",
|
|
headers: {
|
|
Authorization: "private",
|
|
Cookie: "drama_session=private",
|
|
"User-Agent": ua,
|
|
"X-User-Agent": xua,
|
|
"X-Forwarded-For": "203.0.113.10, 10.0.0.1",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal(
|
|
response.headers.get("content-type"),
|
|
"application/vnd.apple.mpegurl",
|
|
);
|
|
assert.equal(await response.text(), playlist);
|
|
assert.equal(response.headers.get("cache-control"), "no-store");
|
|
const upstream = calls.findLast(
|
|
(call) => call.path === "/h5/m3u8/folder/movie.m3u8",
|
|
);
|
|
assert.equal(upstream.search, query);
|
|
assert.equal(upstream.auth, undefined);
|
|
assert.equal(upstream.cookie, undefined);
|
|
assert.equal(upstream.ip, "203.0.113.10");
|
|
assert.equal(upstream.forwarded, undefined);
|
|
assert.equal(upstream.ua, ua);
|
|
assert.equal(upstream.device, xua);
|
|
assert.equal(upstream.accept, "*/*");
|
|
});
|
|
|
|
test("both key endpoint forms proxy exact AES bytes without redirects or credential forwarding", async () => {
|
|
for (const path of ["/enkey", "/enkey/", "/newenkey", "/newenkey/"]) {
|
|
const response = await fetch(base + path + "?token=a%2Fb+1", {
|
|
redirect: "manual",
|
|
headers: { Cookie: "drama_session=private", "X-Real-IP": "203.0.113.11" },
|
|
});
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal(
|
|
response.headers.get("content-type"),
|
|
"application/octet-stream",
|
|
);
|
|
assert.equal(response.headers.get("cache-control"), "no-store");
|
|
assert.equal(response.headers.get("access-control-allow-origin"), "*");
|
|
assert.deepEqual(
|
|
Buffer.from(await response.arrayBuffer()),
|
|
Buffer.from(Array.from({ length: 16 }, (_, index) => index)),
|
|
);
|
|
const upstream = calls.at(-1);
|
|
assert.equal(upstream.path, path.replace(/\/$/, ""));
|
|
assert.equal(upstream.search, "?token=a%2Fb+1");
|
|
assert.equal(upstream.cookie, undefined);
|
|
assert.equal(upstream.ip, "203.0.113.11");
|
|
assert.equal(upstream.forwarded, undefined);
|
|
}
|
|
});
|
|
|
|
test("key endpoints support a legacy upstream slash redirect internally without redirecting the browser or losing signatures", async () => {
|
|
upstreamKeySlash = true;
|
|
try {
|
|
for (const path of ["/enkey", "/newenkey"]) {
|
|
const count = calls.length,
|
|
query = "?id=12700&t=123&ticket=a%2Fb+1&ticket=x%252Fy";
|
|
const response = await fetch(base + path + query, {
|
|
redirect: "manual",
|
|
headers: {
|
|
Cookie: "drama_session=private",
|
|
Authorization: "private",
|
|
"X-Real-IP": "203.0.113.12",
|
|
},
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal((await response.arrayBuffer()).byteLength, 16);
|
|
const forwarded = calls.slice(count);
|
|
assert.deepEqual(
|
|
forwarded.map((call) => call.path),
|
|
[path, path + "/"],
|
|
);
|
|
for (const call of forwarded) {
|
|
assert.equal(call.search, query);
|
|
assert.equal(call.ip, "203.0.113.12");
|
|
assert.equal(call.auth, undefined);
|
|
assert.equal(call.cookie, undefined);
|
|
assert.equal(call.forwarded, undefined);
|
|
}
|
|
}
|
|
} finally {
|
|
upstreamKeySlash = false;
|
|
}
|
|
});
|
|
|
|
test("media byte ranges and upstream failure states remain usable by the player", async () => {
|
|
const segment = await fetch(base + "/h5/m3u8/folder/a.ts?token=signed", {
|
|
headers: { Range: "bytes=0-2" },
|
|
});
|
|
assert.equal(segment.status, 206);
|
|
assert.equal(segment.headers.get("content-range"), "bytes 0-2/10");
|
|
assert.equal(segment.headers.get("content-type"), "video/mp2t");
|
|
assert.deepEqual(
|
|
Buffer.from(await segment.arrayBuffer()),
|
|
Buffer.from([9, 8, 7]),
|
|
);
|
|
assert.equal(calls.at(-1).range, "bytes=0-2");
|
|
for (const path of [
|
|
"/h5/m3u8/denied.m3u8",
|
|
"/h5/m3u8/invalid.m3u8",
|
|
"/h5/m3u8/redirect.m3u8",
|
|
"/enkey?invalid=1",
|
|
"/newenkey/?invalid=1",
|
|
]) {
|
|
const response = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(response.status, 502, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
}
|
|
assert.equal((await fetch(base + "/h5/m3u8/missing.m3u8")).status, 404);
|
|
});
|
|
|
|
function currentMain(html, id) {
|
|
const match = html.match(
|
|
new RegExp('<main\\b[^>]*\\bid="' + id + '"[^>]*>[\\s\\S]*?<\\/main>'),
|
|
);
|
|
assert.ok(match, "main " + id + " must exist");
|
|
assert.ok(
|
|
!/^<main[^>]*\shidden(?:[=\s>])/.test(match[0]),
|
|
id + " must be visible",
|
|
);
|
|
return match[0];
|
|
}
|
|
|
|
test("public listings return their own cards, page links and titles as initial HTML with one shared metadata/body read", async () => {
|
|
try {
|
|
listPayload = (endpoint, url) => {
|
|
const page = Number(url.searchParams.get("pageNum") || 1),
|
|
id = page === 2 ? 24002 : 24001;
|
|
return {
|
|
name: "栏目专属标题",
|
|
desc: "专题简介",
|
|
list: [
|
|
{
|
|
...fixture,
|
|
id,
|
|
title: "列表第" + page + "页内容",
|
|
name: "专题第" + page + "页",
|
|
desc: "专题描述",
|
|
},
|
|
],
|
|
totalCount: 72,
|
|
};
|
|
};
|
|
const routes = [
|
|
["/shorts/", "playlet/operate/list"],
|
|
["/shorts/page/2/", "playlet/operate/list"],
|
|
["/rank/hot/", "playlet/hot/rank/list"],
|
|
["/rank/new/page/2/", "playlet/hot/rank/list"],
|
|
["/rank/follow/", "playlet/hot/rank/list"],
|
|
["/topics/", "playlet/topic/list"],
|
|
["/topics/7/page/2/", "playlet/topic/detail"],
|
|
["/theme/8/", "playlet/all/list"],
|
|
["/tag/9/", "playlet/tag/list"],
|
|
];
|
|
for (const [path, endpoint] of routes) {
|
|
const start = calls.length,
|
|
response = await fetch(base + path);
|
|
assert.equal(response.status, 200, path);
|
|
const html = await response.text(),
|
|
main = currentMain(html, "channel-page"),
|
|
page = path.includes("/page/2/") ? 2 : 1;
|
|
assert.ok(
|
|
main.includes(
|
|
path === "/topics/" ? "专题第1页" : "列表第" + page + "页内容",
|
|
),
|
|
path,
|
|
);
|
|
assert.ok(
|
|
main.includes("/page/3/"),
|
|
path + " must contain native pagination links",
|
|
);
|
|
assert.ok(
|
|
!html.includes("后台配置标题"),
|
|
path + " must not embed the homepage",
|
|
);
|
|
const reads = calls.slice(start);
|
|
assert.equal(
|
|
reads.filter((call) => call.path === "/api/app/" + endpoint).length,
|
|
1,
|
|
path + " must share metadata/body data",
|
|
);
|
|
if (!path.startsWith("/topics/"))
|
|
assert.ok(
|
|
!reads.some((call) => call.path === "/api/app/playlet/home"),
|
|
path,
|
|
);
|
|
assertPageLinksHaveTrailingSlash(main);
|
|
}
|
|
for (const slug of ["hot", "new", "follow"]) {
|
|
const main = currentMain(
|
|
await (await fetch(base + "/rank/" + slug + "/")).text(),
|
|
"channel-page",
|
|
);
|
|
assert.match(
|
|
main,
|
|
new RegExp('href="/rank/' + slug + '/" aria-current="page"'),
|
|
);
|
|
}
|
|
} finally {
|
|
listPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("standalone lists use H2 card titles while cards within named sections remain H3 in initial HTML", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
listPayload = {
|
|
name: "列表名称",
|
|
list: [{ ...fixture, name: "专题卡片" }],
|
|
totalCount: 72,
|
|
};
|
|
homePayload = {
|
|
...savedHome,
|
|
homeConfigData: [
|
|
{ id: 31, name: "短剧分区", status: true, type: 1, list: [fixture] },
|
|
{
|
|
id: 32,
|
|
name: "专题推荐分区",
|
|
status: true,
|
|
type: 3,
|
|
themeList: [{ id: 8, name: "首页专题卡片" }],
|
|
},
|
|
],
|
|
};
|
|
for (const path of [
|
|
"/shorts/",
|
|
"/shorts/page/2/",
|
|
"/rank/hot/",
|
|
"/rank/new/page/2/",
|
|
"/rank/follow/",
|
|
"/topics/",
|
|
"/topics/7/",
|
|
"/theme/8/",
|
|
"/tag/9/",
|
|
"/config/31/",
|
|
"/config/32/",
|
|
]) {
|
|
const response = await fetch(base + path);
|
|
assert.equal(response.status, 200, path);
|
|
const main = currentMain(await response.text(), "channel-page");
|
|
assert.equal((main.match(/<h1\b/g) || []).length, 1, path);
|
|
assert.equal((main.match(/<h2\b/g) || []).length, 1, path);
|
|
assert.ok(!/<h3\b/.test(main), path + " has no intermediate H2 section");
|
|
}
|
|
const home = await (await fetch(base)).text();
|
|
for (const id of [31, 32]) {
|
|
const section = home.match(
|
|
new RegExp(
|
|
'<section\\b[^>]*\\baria-labelledby="home-block-' +
|
|
id +
|
|
'"[^>]*>([\\s\\S]*?)<\\/section>',
|
|
),
|
|
)?.[1];
|
|
assert.ok(section);
|
|
assert.equal((section.match(/<h2\b/g) || []).length, 1);
|
|
assert.equal(
|
|
(section.match(/<h3\b/g) || []).length,
|
|
1,
|
|
"homepage cards are below their section heading",
|
|
);
|
|
}
|
|
const detail = currentMain(
|
|
await (await fetch(base + "/detail/23456/")).text(),
|
|
"drama-play-page",
|
|
);
|
|
assert.match(
|
|
detail,
|
|
/<h2 id="play-recommend-title">推荐视频<\/h2>[\s\S]*?<h3 class="hg-card-title">/,
|
|
);
|
|
const search = currentMain(
|
|
await (await fetch(base + "/search/关键词/")).text(),
|
|
"search-results-page",
|
|
);
|
|
assert.match(search, /<h1\b/);
|
|
assert.match(search, /<h2\b/);
|
|
assert.ok(!/<h3\b/.test(search));
|
|
} finally {
|
|
homePayload = savedHome;
|
|
listPayload = accountListPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("detail and playback pages contain real initial content, the App gate and recommendations without comments, private payloads or denied video sources", async () => {
|
|
try {
|
|
detailSeo = {
|
|
desc: "服务端剧集完整简介",
|
|
token: "private-detail-token",
|
|
reportSdkConfig: { secret: "private-sdk-secret" },
|
|
};
|
|
chapterListPayload = {
|
|
list: [
|
|
{
|
|
id: 90001,
|
|
currentEpisode: 1,
|
|
canWatch: true,
|
|
videoUrl: "https://cdn.example/free.mp4",
|
|
},
|
|
{
|
|
id: 90002,
|
|
currentEpisode: 2,
|
|
canWatch: false,
|
|
needUnlock: true,
|
|
payType: 1,
|
|
singleCoins: 13,
|
|
videoUrl: "https://cdn.example/denied-video-secret.mp4",
|
|
},
|
|
],
|
|
};
|
|
chapterPayload = { ...chapterListPayload.list[1], playletId: 23456 };
|
|
commentPayload = {
|
|
list: [
|
|
{
|
|
id: 45,
|
|
text: "服务端第2页评论",
|
|
userName: "评论用户",
|
|
token: "private-comment-token",
|
|
children: [{ id: 46, text: "嵌套回复" }],
|
|
},
|
|
],
|
|
totalCount: 25,
|
|
};
|
|
videoPayload = {
|
|
...fixture,
|
|
id: 23457,
|
|
title: "服务端独立视频",
|
|
video: {
|
|
url: "https://cdn.example/public-video.mp4",
|
|
cover: fixture.cover,
|
|
},
|
|
desc: "视频真实简介",
|
|
};
|
|
for (const path of ["/detail/23456/", "/play/23456/90002/"]) {
|
|
const start = calls.length,
|
|
response = await fetch(base + path);
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text(),
|
|
main = currentMain(html, "drama-play-page");
|
|
for (const text of ["服务端第2页评论", "嵌套回复", "评论", "反馈", "客服", "金币"])
|
|
assert.ok(!main.includes(text), text);
|
|
assert.ok(main.includes('<h2 id="play-recommend-title">推荐视频</h2>'));
|
|
assert.ok(!html.includes("后台配置标题"));
|
|
for (const secret of [
|
|
"private-detail-token",
|
|
"private-comment-token",
|
|
"private-sdk-secret",
|
|
"denied-video-secret.mp4",
|
|
])
|
|
assert.ok(!html.includes(secret), secret);
|
|
assert.equal(
|
|
calls
|
|
.slice(start)
|
|
.filter((call) => call.path === "/api/app/comment/list").length,
|
|
0,
|
|
"comments are no longer requested",
|
|
);
|
|
if (path.startsWith("/video/")) {
|
|
assert.ok(main.includes("服务端独立视频"));
|
|
assert.ok(main.includes("<video "));
|
|
} else {
|
|
assert.ok(main.includes("服务端剧集完整简介"));
|
|
assert.ok(main.includes('href="/play/23456/90001/"'));
|
|
assert.match(main, /class="film-tile[^"]*\bis-locked" href="\/play\/23456\/90002\/"/);
|
|
assert.ok(main.includes('href="/play/23456/90002/"'));
|
|
assert.ok(!main.includes("data-locked-chapter"));
|
|
if (path.startsWith("/detail/"))
|
|
assert.ok(main.includes("is-overview"));
|
|
else {
|
|
assert.ok(main.includes("<video "));
|
|
assert.ok(main.includes("本集请在 黄果短剧 App 观看"));
|
|
assert.ok(main.includes("请在 App 观看"));
|
|
}
|
|
}
|
|
assertPageLinksHaveTrailingSlash(main);
|
|
}
|
|
} finally {
|
|
detailSeo = undefined;
|
|
chapterPayload = undefined;
|
|
chapterListPayload = undefined;
|
|
commentPayload = undefined;
|
|
videoPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("search paths decode keyword segments once and include keyword/result cards before JavaScript runs", async () => {
|
|
for (const keyword of [
|
|
"都市 爱情",
|
|
"都市 / 爱情 & 100% #1",
|
|
"字面%2F保持原样",
|
|
]) {
|
|
const start = calls.length,
|
|
path = "/search/" + encodeURIComponent(keyword) + "/";
|
|
const response = await fetch(base + path);
|
|
assert.equal(response.status, 200);
|
|
const html = await response.text(),
|
|
main = currentMain(html, "search-results-page");
|
|
const escaped = keyword.replaceAll("&", "&");
|
|
assert.ok(main.includes(escaped), keyword);
|
|
assert.ok(main.includes('href="/detail/23456/"'));
|
|
assert.ok(main.includes("接口剧名 <script>"));
|
|
assert.ok(!html.includes("后台配置标题"));
|
|
assert.ok(!main.includes("<script>alert(1)</script>"));
|
|
const reads = calls
|
|
.slice(start)
|
|
.filter((call) => call.path === "/api/app/playlet/search");
|
|
assert.equal(reads.length, 1);
|
|
assert.equal(new URLSearchParams(reads[0].search).get("keyword"), keyword);
|
|
}
|
|
});
|
|
|
|
test("search path pagination renders keyword links and rejects empty paginated searches", async () => {
|
|
const keyword = "都市 / 爱情 & 100% #1",
|
|
path = "/search/" + encodeURIComponent(keyword) + "/";
|
|
try {
|
|
searchPayload = { list: [fixture], totalCount: 76, pageSize: 25 };
|
|
const start = calls.length,
|
|
response = await fetch(base + path + "page/2/", { redirect: "manual" });
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("location"), null);
|
|
const html = await response.text(),
|
|
main = currentMain(html, "search-results-page");
|
|
for (const href of [path, path + "page/3/"])
|
|
assert.ok(main.includes('href="' + href + '"'), href);
|
|
assert.ok(!main.includes("/search/?q="));
|
|
assert.ok(html.includes("都市 / 爱情 & 100% #1 · 搜索结果 - 第2页"));
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
assert.match(html, /<meta name="robots" content="noindex, follow/);
|
|
const read = calls
|
|
.slice(start)
|
|
.find((call) => call.path === "/api/app/playlet/search");
|
|
assert.equal(new URLSearchParams(read.search).get("keyword"), keyword);
|
|
assert.equal(new URLSearchParams(read.search).get("pageNum"), "2");
|
|
const empty = await fetch(base + "/search/page/2/", { redirect: "manual" });
|
|
assert.equal(empty.status, 404);
|
|
assert.equal(empty.headers.get("location"), null);
|
|
} finally {
|
|
searchPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("Chinese content slugs generate ID-based links in rendered pages and structured data", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
const drama = { ...fixture, slug: "都市故事" };
|
|
homePayload = {
|
|
homeBannerData: [drama],
|
|
hotSearch: [{ keyword: "都市爱情" }],
|
|
homeConfigData: [
|
|
{
|
|
id: 31,
|
|
slug: "精选好剧",
|
|
name: "精选好剧",
|
|
status: true,
|
|
type: 1,
|
|
list: [drama],
|
|
},
|
|
{
|
|
id: 32,
|
|
slug: "专题合集",
|
|
name: "专题合集",
|
|
status: true,
|
|
type: 3,
|
|
themeList: [{ id: 7, slug: "都市专题", name: "都市专题" }],
|
|
},
|
|
],
|
|
};
|
|
const home = await (await fetch(base + "/")).text();
|
|
for (const href of [
|
|
"/detail/23456/",
|
|
"/topics/7/",
|
|
"/config/31/",
|
|
"/search/" + encodeURIComponent("都市爱情") + "/",
|
|
])
|
|
assert.ok(home.includes('href="' + href + '"'), href);
|
|
detailSeo = {
|
|
slug: "都市故事",
|
|
title: "都市故事",
|
|
tagNames: ["都市", "无标识标签"],
|
|
tagIds: [9],
|
|
tagSlugs: ["都市", "中文标签"],
|
|
};
|
|
chapterPayload = {
|
|
id: 90001,
|
|
playletId: 23456,
|
|
currentEpisode: 1,
|
|
canWatch: true,
|
|
videoUrl: "https://media.drama.test/1.mp4",
|
|
};
|
|
const detail = await (await fetch(base + "/play/23456/90001/")).text();
|
|
assert.ok(detail.includes('href="/tag/9/"'));
|
|
assert.ok(detail.includes("<span>#无标识标签</span>"));
|
|
assert.ok(!detail.includes('href="/tag//"'));
|
|
assert.ok(
|
|
detail.includes(
|
|
'rel="canonical" href="https://default.drama.test/play/23456/90001/"',
|
|
),
|
|
);
|
|
for (const html of [home, detail]) {
|
|
for (const [, href] of html.matchAll(/<a\b[^>]*href="(\/[^"#?]*)/g))
|
|
if (!href.startsWith("/search/"))
|
|
assert.ok(!/[^\x00-\x7f]/.test(decodeURIComponent(href)), href);
|
|
}
|
|
} finally {
|
|
homePayload = savedHome;
|
|
detailSeo = chapterPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("a search service outage returns a server error instead of a successful empty search page", async () => {
|
|
try {
|
|
searchFailure = true;
|
|
const response = await fetch(
|
|
base + "/search/" + encodeURIComponent("服务异常") + "/",
|
|
);
|
|
assert.equal(response.status, 500);
|
|
const html = await response.text();
|
|
assert.ok(!html.includes("后台配置标题"));
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
} finally {
|
|
searchFailure = false;
|
|
}
|
|
});
|
|
|
|
test("missing resources return actual 404 responses for browsers and crawlers, without redirects or content canonical", async () => {
|
|
try {
|
|
for (const agent of ["Mozilla/5.0 Chrome/130", "Googlebot", "YandexBot"]) {
|
|
for (const [path, endpoint, override] of [
|
|
["/detail/23456/", "playlet/detail", { status: 404 }],
|
|
["/detail/23456/", "playlet/detail", { code: 404 }],
|
|
["/detail/23456/", "playlet/detail", { code: 410 }],
|
|
["/detail/23456/", "playlet/detail", { code: 5009 }],
|
|
["/detail/23456/", "playlet/detail", { data: null }],
|
|
["/play/23456/90001/", "playlet/chapter/detail", { status: 404 }],
|
|
["/play/23456/90001/", "playlet/chapter/detail", { data: null }],
|
|
["/play/23456/90001/", "playlet/chapter/detail", { code: 5009 }],
|
|
["/topics/7/", "playlet/topic/detail", { code: 404 }],
|
|
["/topics/7/", "playlet/topic/detail", { code: 5009 }],
|
|
[
|
|
"/topics/7/",
|
|
"playlet/topic/detail",
|
|
{ data: { id: 8, name: "其他专题", list: [], totalCount: 0 } },
|
|
],
|
|
["/theme/7/", "playlet/all/list", { status: 404 }],
|
|
["/tag/7/", "playlet/tag/list", { code: 410 }],
|
|
]) {
|
|
responseOverrides = { [endpoint]: override };
|
|
const response = await fetch(base + path, {
|
|
redirect: "manual",
|
|
headers: { "User-Agent": agent },
|
|
}),
|
|
html = await response.text();
|
|
assert.equal(
|
|
response.status,
|
|
404,
|
|
agent + " " + path + " " + JSON.stringify(override),
|
|
);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.ok(html.includes("noindex"));
|
|
assert.match(response.headers.get("cache-control"), /no-store/);
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
|
assert.ok(html.includes("返回首页"));
|
|
}
|
|
}
|
|
responseOverrides = {};
|
|
for (const path of [
|
|
"/config/999999/",
|
|
"/detail/999999/",
|
|
"/play/23456/999999/",
|
|
"/tag/999999/",
|
|
]) {
|
|
const response = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(response.status, 404, path);
|
|
await response.text();
|
|
}
|
|
for (const path of ["/detail/23456/", "/play/23456/90001/"]) {
|
|
const response = await fetch(base + path);
|
|
assert.equal(
|
|
response.status,
|
|
200,
|
|
"valid/locked content stays available",
|
|
);
|
|
await response.text();
|
|
}
|
|
} finally {
|
|
responseOverrides = {};
|
|
}
|
|
});
|
|
|
|
test("known pagination limits return 404 while existing empty first pages remain 200", async () => {
|
|
try {
|
|
listPayload = { list: [], totalCount: 0 };
|
|
for (const path of [
|
|
"/shorts/",
|
|
"/rank/hot/",
|
|
"/rank/new/",
|
|
"/rank/follow/",
|
|
"/topics/",
|
|
"/topics/7/",
|
|
"/theme/8/",
|
|
"/tag/9/",
|
|
]) {
|
|
const first = await fetch(base + path);
|
|
assert.equal(first.status, 200, path);
|
|
const emptyHtml = await first.text();
|
|
assert.match(
|
|
emptyHtml,
|
|
/<meta name="robots" content="noindex,\s*follow"/,
|
|
);
|
|
assert.ok(!emptyHtml.includes('rel="canonical"'));
|
|
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(emptyHtml));
|
|
const overflow = await fetch(base + path + "page/2/", {
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(overflow.status, 404, path);
|
|
await overflow.text();
|
|
}
|
|
listPayload = { list: [fixture], totalCount: 48 };
|
|
commentPayload = transactionsPayload = { list: [], totalCount: 20 };
|
|
accountListPayload = { list: [], totalCount: 48 };
|
|
searchPayload = { list: [fixture], totalCount: 48, pageSize: 24 };
|
|
for (const path of [
|
|
"/shorts/",
|
|
"/rank/hot/",
|
|
"/rank/new/",
|
|
"/rank/follow/",
|
|
"/topics/",
|
|
"/topics/7/",
|
|
"/theme/8/",
|
|
"/tag/9/",
|
|
"/search/关键词/",
|
|
]) {
|
|
for (const [page, status] of [
|
|
[2, 200],
|
|
[3, 404],
|
|
]) {
|
|
const privatePath=/^\/(member\/|info\/deposit)/.test(path);
|
|
const response = await fetch(base + path + "page/" + page + "/", {
|
|
redirect: "manual",
|
|
headers: privatePath
|
|
? { Cookie: "drama_bearer=test-session-secret" }
|
|
: undefined,
|
|
}),
|
|
html = await response.text();
|
|
assert.equal(response.status, status, path + " page " + page);
|
|
assert.equal(response.headers.get("location"), null);
|
|
if (status === 404) {
|
|
assert.ok(html.includes("noindex"));
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
assert.ok(!html.includes('rel="next"'));
|
|
}
|
|
}
|
|
}
|
|
for (const path of ["/config/31/page/2/", "/detail/23456/page/2/", "/play/23456/90001/page/2/", "/member/history/page/2/", "/info/deposit/page/2/"]) {
|
|
const removed = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(removed.status, 404, path);
|
|
await removed.text();
|
|
}
|
|
searchPayload = { list: [], totalCount: 0 };
|
|
const emptySearch = await fetch(base + "/search/没有匹配项/");
|
|
assert.equal(emptySearch.status, 200);
|
|
assert.ok((await emptySearch.text()).includes("搜索无结果"));
|
|
} finally {
|
|
listPayload =
|
|
commentPayload =
|
|
transactionsPayload =
|
|
accountListPayload =
|
|
searchPayload =
|
|
undefined;
|
|
}
|
|
});
|
|
|
|
test("independent theme/topic resources and leading-zero IDs retain their primary canonical", async () => {
|
|
try {
|
|
themePayload = { list: [{ id: 7, name: "同一题材" }] };
|
|
listPayload = {
|
|
name: "同一专题",
|
|
desc: "同一专题介绍",
|
|
list: [fixture],
|
|
totalCount: 72,
|
|
};
|
|
for (const [path, target] of [
|
|
["/theme/007/", "/theme/7/"],
|
|
["/theme/007/page/2/", "/theme/7/page/2/"],
|
|
["/topics/007/", "/topics/7/"],
|
|
["/detail/023456/", "/detail/23456/"],
|
|
]) {
|
|
const start = calls.length,
|
|
response = await fetch(base + path + "?utm_source=campaign", {
|
|
redirect: "manual",
|
|
}),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.ok(
|
|
html.includes(
|
|
`rel="canonical" href="https://default.drama.test${target}"`,
|
|
),
|
|
path,
|
|
);
|
|
assert.match(html, /<meta name="robots" content="index,\s*follow/);
|
|
assert.ok(
|
|
html.includes(
|
|
path.startsWith("/detail/")
|
|
? "play-detail-title"
|
|
: path.startsWith("/theme/")
|
|
? "同一题材"
|
|
: "同一专题",
|
|
),
|
|
);
|
|
if (path.startsWith("/theme/"))
|
|
assert.equal(
|
|
calls
|
|
.slice(start)
|
|
.filter((call) => call.path === "/api/app/playlet/all/list").length,
|
|
1,
|
|
);
|
|
}
|
|
} finally {
|
|
listPayload = themePayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("former comment pages are gone, while ordinary catalog pagination remains indexable", async () => {
|
|
try {
|
|
commentPayload = {
|
|
list: [{ id: 321, text: "第二页的真实评论" }],
|
|
totalCount: 25,
|
|
};
|
|
chapterPayload = {
|
|
id: 90001,
|
|
playletId: 23456,
|
|
currentEpisode: 2,
|
|
canWatch: true,
|
|
videoUrl: "https://media.drama.test/2.mp4",
|
|
};
|
|
for (const path of ["/detail/23456/page/2/", "/play/23456/90001/page/2/"]) {
|
|
const response = await fetch(base + path, {
|
|
redirect: "manual",
|
|
headers: { "User-Agent": "YandexBot" },
|
|
}),
|
|
html = await response.text();
|
|
assert.equal(response.status, 404, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.match(html, /<meta name="robots" content="noindex/);
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
assert.ok(!html.includes("第二页的真实评论"));
|
|
}
|
|
listPayload = { list: [fixture], totalCount: 72 };
|
|
const html = await (await fetch(base + "/shorts/page/2/")).text();
|
|
assert.match(html, /<meta name="robots" content="index,\s*follow/);
|
|
assert.ok(
|
|
html.includes(
|
|
'rel="canonical" href="https://default.drama.test/shorts/page/2/"',
|
|
),
|
|
);
|
|
} finally {
|
|
commentPayload = chapterPayload = listPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("SSR applies more-list content rules and real episode access rules without exposing locked media", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
const block = {
|
|
id: 31,
|
|
status: true,
|
|
name: "都市精选",
|
|
type: 1,
|
|
list: [fixture],
|
|
};
|
|
for (const [desc, indexable] of [
|
|
["", false],
|
|
["精选都市短剧与独立栏目介绍", true],
|
|
]) {
|
|
homePayload = { ...savedHome, homeConfigData: [{ ...block, desc }] };
|
|
const response = await fetch(base + "/config/31/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(/<meta name="robots" content="index,/.test(html), indexable);
|
|
assert.equal(html.includes('rel="canonical"'), indexable);
|
|
assert.ok(currentMain(html, "channel-page").includes("都市精选"));
|
|
}
|
|
detailSeo = { desc: "剧集共用简介", onlineAt: "2025-10-18 10:30:00" };
|
|
const chapter = {
|
|
id: 90001,
|
|
playletId: 23456,
|
|
currentEpisode: 3,
|
|
payType: 1,
|
|
videoUrl: "https://media.drama.test/paid-episode.m3u8",
|
|
};
|
|
for (const allowed of [false, true]) {
|
|
chapterPayload = { ...chapter, canWatch: allowed, needUnlock: !allowed };
|
|
const response = await fetch(base + "/play/23456/90001/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(/<meta name="robots" content="index,/.test(html), allowed);
|
|
assert.equal(html.includes('rel="canonical"'), allowed);
|
|
assert.equal(html.includes(chapter.videoUrl), allowed);
|
|
assert.ok(html.includes('class="play-screen play-paywalled-content"'));
|
|
if (allowed) {
|
|
const graph = JSON.parse(
|
|
html.match(
|
|
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/,
|
|
)[1],
|
|
)["@graph"];
|
|
const page = graph.find((node) => node["@type"] === "WebPage");
|
|
assert.equal(page.isAccessibleForFree, false);
|
|
assert.equal(page.hasPart.cssSelector, ".play-paywalled-content");
|
|
assert.equal(
|
|
graph.find((node) => node["@type"] === "VideoObject")
|
|
.isAccessibleForFree,
|
|
false,
|
|
);
|
|
} else assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
|
}
|
|
chapterPayload = {
|
|
...chapter,
|
|
canWatch: true,
|
|
needUnlock: false,
|
|
payType: 0,
|
|
videoUrl: "",
|
|
};
|
|
const html = await (await fetch(base + "/play/23456/90001/")).text();
|
|
assert.match(html, /<meta name="robots" content="noindex/);
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
} finally {
|
|
homePayload = savedHome;
|
|
detailSeo = chapterPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("primary data outages and malformed responses return 5xx rather than 200 or a permanent 404", async () => {
|
|
try {
|
|
for (const agent of ["Mozilla/5.0 Chrome/130", "Googlebot", "YandexBot"]) {
|
|
for (const [path, endpoint, override] of [
|
|
["/", "playlet/home", { status: 503 }],
|
|
["/", "playlet/home", { data: null }],
|
|
["/", "playlet/home", { raw: "invalid json" }],
|
|
["/shorts/", "playlet/operate/list", { status: 503 }],
|
|
["/shorts/page/9/", "playlet/operate/list", { code: 5100 }],
|
|
["/shorts/", "playlet/operate/list", { status: 404 }],
|
|
["/shorts/", "playlet/operate/list", { data: null }],
|
|
["/detail/23456/", "playlet/detail", { status: 503 }],
|
|
["/detail/23456/", "playlet/detail", { code: 5100 }],
|
|
["/detail/23456/", "playlet/detail", { raw: "invalid json" }],
|
|
["/detail/23456/", "playlet/detail", { data: "bad" }],
|
|
["/detail/23456/", "playlet/detail", { data: { id: 23456 } }],
|
|
["/play/23456/90001/", "playlet/chapter/detail", { status: 503 }],
|
|
["/topics/7/", "playlet/topic/detail", { status: 503 }],
|
|
["/search/关键词/", "playlet/search", { code: 5100 }],
|
|
["/navigation/", "config/info", { status: 503 }],
|
|
["/home-road/", "config/info", { status: 503 }],
|
|
["/tag/9/", "playlet/tag/options", { status: 503 }],
|
|
["/tag/9/", "playlet/tag/options", { status: 404 }],
|
|
["/tag/9/", "playlet/tag/options", { code: 5009 }],
|
|
]) {
|
|
responseOverrides = { [endpoint]: override };
|
|
const response = await fetch(base + path, {
|
|
redirect: "manual",
|
|
headers: { "User-Agent": agent },
|
|
}),
|
|
html = await response.text();
|
|
assert.equal(
|
|
response.status,
|
|
500,
|
|
agent + " " + path + " " + JSON.stringify(override),
|
|
);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.match(response.headers.get("cache-control"), /no-store/);
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
|
assert.ok(!html.includes("test-session-secret"));
|
|
}
|
|
}
|
|
responseOverrides = {};
|
|
const recovered = await fetch(base + "/shorts/");
|
|
assert.equal(recovered.status, 200);
|
|
await recovered.text();
|
|
} finally {
|
|
responseOverrides = {};
|
|
}
|
|
});
|
|
|
|
test("HEAD requests report the same missing-resource and temporary-error statuses as GET requests", async () => {
|
|
try {
|
|
for (const [path, endpoint, override, status] of [
|
|
["/detail/23456/", "playlet/detail", { code: 5009 }, 404],
|
|
[
|
|
"/tag/999999/",
|
|
"playlet/tag/list",
|
|
{ data: { list: [], totalCount: 0 } },
|
|
404,
|
|
],
|
|
[
|
|
"/shorts/page/2/",
|
|
"playlet/operate/list",
|
|
{ data: { list: [], totalCount: 0 } },
|
|
404,
|
|
],
|
|
["/detail/23456/", "playlet/detail", { status: 503 }, 500],
|
|
["/", "playlet/home", { status: 503 }, 500],
|
|
]) {
|
|
responseOverrides = { [endpoint]: override };
|
|
const response = await fetch(base + path, {
|
|
method: "HEAD",
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(response.status, status, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.equal(await response.text(), "");
|
|
}
|
|
} finally {
|
|
responseOverrides = {};
|
|
}
|
|
});
|
|
|
|
test("optional data failures preserve real detail content", async () => {
|
|
try {
|
|
responseOverrides = {
|
|
"comment/list": { status: 503 },
|
|
"playlet/chapter/list": { status: 503 },
|
|
"playlet/guess-like/list": { status: 503 },
|
|
};
|
|
for (const path of ["/detail/23456/", "/play/23456/90001/"]) {
|
|
const response = await fetch(base + path);
|
|
assert.equal(response.status, 200, path);
|
|
const html = await response.text();
|
|
assert.ok(html.includes("接口剧名"));
|
|
assert.ok(html.includes("暂无推荐视频"));
|
|
assert.ok(!html.includes("评论"));
|
|
}
|
|
} finally {
|
|
responseOverrides = {};
|
|
}
|
|
});
|
|
|
|
test("current Swagger contracts drive SSR listings, flat topic detail, home more and detail recommendations", async () => {
|
|
const start = calls.length;
|
|
try {
|
|
strictContract = true;
|
|
listPayload = {
|
|
name: "专题真实标题",
|
|
desc: "顶层专题简介",
|
|
list: [{ ...fixture, id: 24001 }],
|
|
totalCount: 72,
|
|
};
|
|
for (const path of [
|
|
"/shorts/page/2/?sort=2",
|
|
"/rank/new/page/2/",
|
|
"/topics/7/page/2/?sort=2",
|
|
"/theme/8/",
|
|
"/tag/9/?sort=2",
|
|
]) {
|
|
const response = await fetch(base + path);
|
|
assert.equal(response.status, 200, path);
|
|
const html = await response.text();
|
|
assert.ok(html.includes('href="/detail/24001/"'), path);
|
|
if (path.includes("/topics/7/"))
|
|
assert.ok(currentMain(html, "channel-page").includes("顶层专题简介"));
|
|
if (path.startsWith("/theme/"))
|
|
assert.ok(currentMain(html, "channel-page").includes("题材8"));
|
|
}
|
|
let html = await (await fetch(base + "/config/31/")).text();
|
|
assert.ok(currentMain(html, "channel-page").includes("后台配置标题"));
|
|
assert.ok(
|
|
currentMain(html, "channel-page").includes('href="/detail/23456/"'),
|
|
);
|
|
assert.ok(!currentMain(html, "channel-page").includes("/page/2/"));
|
|
html = await (await fetch(base + "/detail/23456/")).text();
|
|
assert.ok(currentMain(html, "drama-play-page").includes("同类推荐短剧"));
|
|
const related = calls
|
|
.slice(start)
|
|
.find((call) => call.path === "/api/app/playlet/guess-like/list");
|
|
assert.equal(new URLSearchParams(related.search).get("playletId"), "23456");
|
|
assert.ok(
|
|
!calls
|
|
.slice(start)
|
|
.some((call) =>
|
|
/\/video\/|playlet\/hot\/list|home\/config\/detail/.test(call.path),
|
|
),
|
|
);
|
|
} finally {
|
|
strictContract = false;
|
|
listPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("new Swagger modules, grouped topics, config pagination and direct topic slugs render complete SSR pages", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
strictContract = true;
|
|
const works = Array.from({ length: 49 }, (_, index) => ({
|
|
...fixture,
|
|
id: 24000 + index,
|
|
slug: "work-" + index,
|
|
}));
|
|
homePayload = {
|
|
homeBannerData: [works[0]],
|
|
modules: [
|
|
{
|
|
id: 31,
|
|
slug: "editors",
|
|
title: "新首页栏目",
|
|
desc: "独立栏目简介",
|
|
moduleType: 1,
|
|
style: "A",
|
|
limit: 3,
|
|
list: works,
|
|
},
|
|
{
|
|
id: 32,
|
|
title: "题材模块",
|
|
desc: "题材介绍",
|
|
moduleType: 2,
|
|
style: "E",
|
|
topicList: [{ id: 9, slug: "urban", name: "都市题材" }],
|
|
},
|
|
],
|
|
};
|
|
themePayload = {
|
|
list: [
|
|
{
|
|
id: 9,
|
|
slug: "urban",
|
|
name: "都市题材",
|
|
seoInfo: { title: "题材独立标题" },
|
|
},
|
|
],
|
|
};
|
|
const groups = [
|
|
{
|
|
id: 201,
|
|
name: "热门剧单",
|
|
desc: "专题区块介绍",
|
|
totalCount: 49,
|
|
list: [{ id: 7, slug: "city-topic", name: "都市专题" }],
|
|
},
|
|
{
|
|
id: 202,
|
|
name: "近期专题",
|
|
totalCount: 25,
|
|
list: [{ id: 8, name: "古装专题" }],
|
|
},
|
|
];
|
|
listPayload = (endpoint) =>
|
|
endpoint === "playlet/topic/list"
|
|
? {
|
|
list: groups,
|
|
pageNum: 2,
|
|
pageSize: 24,
|
|
seoInfo: { title: "专题独立标题" },
|
|
}
|
|
: endpoint === "playlet/topic/detail"
|
|
? {
|
|
id: 7,
|
|
slug: "city-topic",
|
|
name: "都市专题",
|
|
list: [works[0]],
|
|
totalCount: 49,
|
|
}
|
|
: { list: [works[0]], totalCount: 49 };
|
|
let response = await fetch(base + "/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
for (const text of [
|
|
"新首页栏目",
|
|
"题材模块",
|
|
'href="/theme/urban/"',
|
|
'href="/config/editors/"',
|
|
])
|
|
assert.ok(html.includes(text), text);
|
|
assert.ok(html.includes('data-home-style="A"'));
|
|
assert.ok(html.includes('data-home-style="E"'));
|
|
assert.ok(html.includes('href="/detail/work-2/"'));
|
|
assert.ok(!html.includes('href="/detail/work-6/"'));
|
|
response = await fetch(base + "/config/editors/page/2/");
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
const main = currentMain(html, "channel-page");
|
|
assert.ok(main.includes("/detail/work-24/"));
|
|
assert.ok(!main.includes("/detail/work-0/"));
|
|
assert.ok(main.includes("/config/editors/page/3/"));
|
|
const configRead = calls.findLast(
|
|
(call) => call.path === "/api/app/playlet/home-config/list",
|
|
);
|
|
assert.equal(new URLSearchParams(configRead.search).get("pageNum"), "2");
|
|
response = await fetch(base + "/topics/page/2/");
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
for (const text of [
|
|
"热门剧单",
|
|
"近期专题",
|
|
'href="/topics/city-topic/"',
|
|
"/topics/page/3/",
|
|
])
|
|
assert.ok(currentMain(html, "channel-page").includes(text), text);
|
|
assert.ok(!currentMain(html, "channel-page").includes("/topics/page/4/"));
|
|
const start = calls.length;
|
|
response = await fetch(base + "/topics/city-topic/");
|
|
assert.equal(response.status, 200);
|
|
await response.text();
|
|
assert.ok(
|
|
!calls
|
|
.slice(start)
|
|
.some((call) => call.path === "/api/app/playlet/topic/list"),
|
|
);
|
|
assert.equal(
|
|
new URLSearchParams(
|
|
calls.findLast((call) => call.path === "/api/app/playlet/topic/detail")
|
|
.search,
|
|
).get("topicSlug"),
|
|
"city-topic",
|
|
);
|
|
response = await fetch(base + "/theme/urban/?sort=3&updateStatus=2");
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
const themeQuery = new URLSearchParams(
|
|
calls.findLast((call) => call.path === "/api/app/playlet/all/list")
|
|
.search,
|
|
);
|
|
assert.equal(themeQuery.get("themeSlug"), "urban");
|
|
assert.equal(themeQuery.get("updateStatus"), "2");
|
|
assert.equal(themeQuery.get("sortType"), "3");
|
|
assert.match(html, /<meta name="robots" content="noindex/);
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
} finally {
|
|
homePayload = savedHome;
|
|
themePayload = listPayload = undefined;
|
|
strictContract = false;
|
|
}
|
|
});
|
|
|
|
test("homepage SSR renders each category style directly from API configuration", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
const styles = ["D", "A", "E", "C", "B"];
|
|
const counts = { A: 3, B: 9, C: 8, D: 7, E: 5 };
|
|
homePayload = {
|
|
modules: styles.map((style, index) => ({
|
|
id: 31 + index,
|
|
title: "后台分类",
|
|
slug: "section-" + style.toLowerCase(),
|
|
style,
|
|
limit: counts[style],
|
|
moduleType: style === "E" ? 2 : 1,
|
|
label: "上新推荐",
|
|
list: Array.from({ length: 12 }, (_, item) => ({
|
|
...fixture,
|
|
id: 25000 + index * 100 + item,
|
|
slug: "style-" + style.toLowerCase() + "-" + item,
|
|
})),
|
|
topicList: Array.from({ length: 12 }, (_, item) => ({
|
|
id: 100 + item,
|
|
name: "主题 " + item,
|
|
slug: "topic-" + item,
|
|
})),
|
|
})),
|
|
};
|
|
const response = await fetch(base + "/"),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
const blocks = [
|
|
...html.matchAll(
|
|
/<section\b[^>]*\bdata-home-style="([A-E])"[^>]*>[\s\S]*?<\/section>/g,
|
|
),
|
|
];
|
|
assert.deepEqual(
|
|
blocks.map((match) => match[1]),
|
|
styles,
|
|
);
|
|
assert.ok(html.includes('class="topic-section romance-editorial home-block" data-home-style="A"'));
|
|
assert.ok(html.includes('id="library" class="home-block" data-home-style="B"'));
|
|
assert.ok(html.includes('class="topic-section topic-recommendations home-block" data-home-style="E"'));
|
|
for (const [markup, style] of blocks) {
|
|
assert.ok(!markup.includes("data-track"), style);
|
|
assert.equal(
|
|
(
|
|
markup.match(
|
|
/class="(?:hg-card|curated-card drama-data-card|ranked-card drama-data-card|topic-recommendation-card)"/g,
|
|
) || []
|
|
).length,
|
|
counts[style],
|
|
style,
|
|
);
|
|
assert.ok(
|
|
markup.includes(`href="/config/section-${style.toLowerCase()}/"`),
|
|
);
|
|
if (style === "C")
|
|
assert.ok(markup.includes('class="curated-card drama-data-card"'));
|
|
if (style === "D")
|
|
assert.ok(markup.includes('aria-label="第 7 名,查看'));
|
|
if (style === "E") assert.ok(markup.includes('href="/theme/topic-0/"'));
|
|
}
|
|
} finally {
|
|
homePayload = savedHome;
|
|
}
|
|
});
|
|
|
|
test("removed series and standalone video URLs return 404 without slash redirects or content reads", async () => {
|
|
const before = calls.length;
|
|
for (const path of [
|
|
"/series",
|
|
"/series/",
|
|
"/series/page/2",
|
|
"/series/page/2/",
|
|
"/video/23456",
|
|
"/video/23456/",
|
|
"/video/23456/page/2",
|
|
"/video/23456/page/2/",
|
|
]) {
|
|
const response = await fetch(base + path + "?from=old-menu", {
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(response.status, 404, path);
|
|
assert.equal(response.headers.get("location"), null, path);
|
|
const html = await response.text();
|
|
assert.match(html, /<meta name="robots" content="noindex/);
|
|
}
|
|
assert.equal(calls.length, before);
|
|
const html = await (await fetch(base)).text();
|
|
assert.ok(!html.includes('href="/series/"'));
|
|
assert.ok(!html.includes('data-page="series"'));
|
|
const runtime = await (await fetch(base + "/scripts/site-runtime.js")).text();
|
|
assert.ok(!runtime.includes("/series/"));
|
|
assert.ok(html.includes('href="/shorts/"'));
|
|
});
|
|
|
|
function navigationHtml(html, surface) {
|
|
const start = html.indexOf('data-site-navigation="' + surface + '"');
|
|
assert.ok(start >= 0);
|
|
return html.slice(start, html.indexOf("</nav>", start));
|
|
}
|
|
|
|
test("top and sidebar use API names, order, destinations and groups in initial HTML without fixed entries", async () => {
|
|
const before = calls.length;
|
|
try {
|
|
listPayload = { list: [fixture], totalCount: 48 };
|
|
headerPayload = {
|
|
categoryList: [
|
|
{
|
|
id: 80,
|
|
categoryType: 1,
|
|
name: "精选专题 <精品>",
|
|
urlValue: "/topics/",
|
|
},
|
|
{
|
|
id: 81,
|
|
categoryType: 2,
|
|
name: "发现好剧",
|
|
translationList: [
|
|
{ id: 82, name: "新剧排行", urlValue: "/rank/new/" },
|
|
{
|
|
id: 83,
|
|
name: "官方社区",
|
|
urlValue: "https://example.net/community",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 84,
|
|
categoryType: 1,
|
|
name: "全部好剧",
|
|
urlValue: "/shorts/",
|
|
icon: "/assets/nav-custom.webp",
|
|
},
|
|
{
|
|
id: 85,
|
|
categoryType: 3,
|
|
name: "只在底部",
|
|
urlValue: "/info/about-us/",
|
|
},
|
|
{ id: 86, categoryType: 1, name: "旧剧集", urlValue: "/series/" },
|
|
{
|
|
id: 87,
|
|
categoryType: 1,
|
|
name: "恶意地址",
|
|
urlValue: "javascript:alert(1)",
|
|
},
|
|
],
|
|
};
|
|
const html = await (await fetch(base + "/shorts/page/2/")).text();
|
|
for (const surface of ["top", "sidebar"]) {
|
|
const nav = navigationHtml(html, surface);
|
|
assert.ok(nav.indexOf("精选专题 <精品>") < nav.indexOf("发现好剧"));
|
|
assert.ok(nav.indexOf("发现好剧") < nav.indexOf("全部好剧"));
|
|
assert.ok(nav.includes('href="/rank/new/"'));
|
|
assert.ok(nav.includes('href="/shorts/"'));
|
|
assert.ok(nav.includes('data-site-nav="' + surface + '"'));
|
|
assert.ok(nav.includes("<details"));
|
|
assert.ok(nav.includes('rel="sponsored nofollow noopener noreferrer"'));
|
|
assert.ok(nav.includes('aria-current="page"'));
|
|
for (const value of [
|
|
"只在底部",
|
|
"旧剧集",
|
|
"恶意地址",
|
|
'href="/home-road/"',
|
|
'href="/apps/"',
|
|
])
|
|
assert.ok(!nav.includes(value), value);
|
|
}
|
|
assert.ok(
|
|
navigationHtml(html, "sidebar").includes(
|
|
'data-src="/assets/nav-custom.webp"',
|
|
),
|
|
);
|
|
assert.equal(
|
|
calls
|
|
.slice(before)
|
|
.filter((call) => call.path === "/api/app/navigation/header").length,
|
|
1,
|
|
);
|
|
headerPayload = { categoryList: [] };
|
|
const empty = await (await fetch(base + "/shorts/")).text();
|
|
assert.ok(!navigationHtml(empty, "top").includes("<a "));
|
|
assert.ok(!navigationHtml(empty, "sidebar").includes("<a "));
|
|
} finally {
|
|
headerPayload = defaultHeader;
|
|
listPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("navigation failure leaves page content available and never restores a hardcoded menu", async () => {
|
|
try {
|
|
headerFailure = true;
|
|
const response = await fetch(base),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200);
|
|
assert.ok(html.includes("后台配置标题"));
|
|
assert.ok(navigationHtml(html, "top").includes("导航暂不可用"));
|
|
assert.ok(navigationHtml(html, "sidebar").includes("导航暂不可用"));
|
|
assert.ok(!navigationHtml(html, "top").includes("<a "));
|
|
} finally {
|
|
headerFailure = false;
|
|
}
|
|
});
|
|
|
|
test("return-home and contacts arrive as complete HTML without a loading shell; the app directory is gone", async () => {
|
|
for (const [path, panel, marker] of [
|
|
["/home-road/", "channel-page", "return-home-directory"],
|
|
["/info/about-us/", "info-page", "https://t.me/jbd125"],
|
|
]) {
|
|
const response = await fetch(base + path);
|
|
assert.equal(response.status, 200, path);
|
|
const html = await response.text(),
|
|
main = currentMain(html, panel);
|
|
assert.ok(main.includes(marker), path);
|
|
assert.ok(!/api-loading|正在加载|加载中/.test(main), path);
|
|
assert.ok(!html.includes('href="/apps/"'), path);
|
|
assert.ok(!html.includes("福利应用"), path);
|
|
}
|
|
const start = calls.length;
|
|
for (const path of ["/apps/", "/apps"]) {
|
|
const removed = await fetch(base + path, { redirect: "manual" });
|
|
assert.equal(removed.status, 404, path);
|
|
await removed.arrayBuffer();
|
|
}
|
|
assert.ok(!calls.slice(start).some((call) => call.path === "/api/app/applist/list"));
|
|
});
|
|
|
|
test("header and complete page content share the server-prepared identity before scripts run", async () => {
|
|
const start = calls.length,
|
|
response = await fetch(base + "/shorts/", {
|
|
headers: { Cookie: "drama_bearer=test-session-secret" },
|
|
}),
|
|
html = await response.text();
|
|
assert.ok(
|
|
html.indexOf('id="global-site-header"') <
|
|
html.indexOf('<main class="shell"'),
|
|
);
|
|
assert.ok(html.includes('class="hg-header-brand"'));
|
|
assert.ok(html.includes("接口昵称") || html.includes("真实接口用户"));
|
|
assert.ok(!html.includes("正在连接"));
|
|
assert.equal(
|
|
calls.slice(start).filter((call) => call.path === "/api/app/auth/guest")
|
|
.length,
|
|
0,
|
|
);
|
|
assert.ok(
|
|
calls.slice(start).some((call) => call.path === "/api/app/auth/getInfo"),
|
|
);
|
|
for (const secret of ["test-session-secret", "drama_session"])
|
|
assert.ok(!html.includes(secret));
|
|
});
|
|
|
|
test("slug details and episodes render directly with numeric dependent reads, slug links and matching canonical schema", async () => {
|
|
try {
|
|
strictContract = true;
|
|
detailSeo = {
|
|
slug: "city-story",
|
|
title: "都市故事",
|
|
desc: "作品独立介绍",
|
|
tagNames: ["都市"],
|
|
tagIds: [9],
|
|
tagSlugs: ["urban"],
|
|
onlineAt: "2026-10-20",
|
|
};
|
|
chapterPayload = {
|
|
id: 90001,
|
|
playletId: 23456,
|
|
currentEpisode: 3,
|
|
canWatch: true,
|
|
videoUrl: "https://media.drama.test/3.mp4",
|
|
};
|
|
commentPayload = { list: [], totalCount: 25 };
|
|
for (const [path, chapter] of [
|
|
["/detail/city-story/", false],
|
|
["/play/city-story/90001/", true],
|
|
]) {
|
|
const start = calls.length,
|
|
response = await fetch(base + path, { redirect: "manual" }),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
const main = currentMain(html, "drama-play-page");
|
|
assert.ok(main.includes("都市故事"));
|
|
assert.ok(main.includes('href="/play/city-story/90001/"'));
|
|
assert.ok(!main.includes(`href="${path}page/2/"`));
|
|
assert.ok(
|
|
html.includes(
|
|
`rel="canonical" href="https://default.drama.test${path}"`,
|
|
),
|
|
);
|
|
const reads = calls.slice(start),
|
|
detail = reads.filter(
|
|
(call) => call.path === "/api/app/playlet/detail",
|
|
);
|
|
assert.equal(detail.length, 1);
|
|
assert.equal(
|
|
new URLSearchParams(detail[0].search).get("slug"),
|
|
"city-story",
|
|
);
|
|
assert.equal(new URLSearchParams(detail[0].search).has("id"), false);
|
|
assert.equal(
|
|
new URLSearchParams(
|
|
reads.find((call) => call.path === "/api/app/playlet/chapter/list")
|
|
.search,
|
|
).get("playletSlug"),
|
|
"city-story",
|
|
);
|
|
assert.equal(
|
|
new URLSearchParams(
|
|
reads.find((call) => call.path === "/api/app/playlet/guess-like/list")
|
|
.search,
|
|
).get("playletId"),
|
|
"23456",
|
|
);
|
|
assert.ok(!reads.some((call) => call.path === "/api/app/comment/list"));
|
|
assert.equal(
|
|
new URLSearchParams(
|
|
reads.find((call) => call.path === "/api/app/playlet/guess-like/list")
|
|
.search,
|
|
).get("pageSize"),
|
|
"12",
|
|
);
|
|
if (chapter) {
|
|
assert.ok(main.includes('href="/tag/urban/"'));
|
|
assert.ok(main.includes('href="/detail/city-story/"'));
|
|
}
|
|
const graph = JSON.parse(
|
|
html.match(
|
|
/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/,
|
|
)[1],
|
|
)["@graph"];
|
|
assert.equal(
|
|
graph.find((node) => node["@type"] === "TVSeries").url,
|
|
"https://default.drama.test/detail/city-story/",
|
|
);
|
|
if (chapter)
|
|
assert.equal(
|
|
graph.find((node) => node["@type"] === "TVEpisode").url,
|
|
"https://default.drama.test/play/city-story/90001/",
|
|
);
|
|
}
|
|
const legacy = await fetch(base + "/detail/23456/", { redirect: "manual" }),
|
|
html = await legacy.text();
|
|
assert.equal(legacy.status, 200);
|
|
assert.equal(legacy.headers.get("location"), null);
|
|
assert.ok(
|
|
html.includes(
|
|
'rel="canonical" href="https://default.drama.test/detail/city-story/"',
|
|
),
|
|
);
|
|
assert.ok(!html.includes('href="/detail/city-story/page/2/"'));
|
|
detailSeo = { ...detailSeo, slug: "都市 / 剧?#%2F" };
|
|
const path = "/play/" + encodeURIComponent(detailSeo.slug) + "/90001/";
|
|
const special = await fetch(base + path);
|
|
assert.equal(special.status, 200);
|
|
const specialHtml = await special.text();
|
|
assert.ok(
|
|
specialHtml.includes(
|
|
'rel="canonical" href="https://default.drama.test/play/23456/90001/"',
|
|
),
|
|
);
|
|
assert.ok(specialHtml.includes('href="/play/23456/90001/"'));
|
|
} finally {
|
|
strictContract = false;
|
|
detailSeo = chapterPayload = commentPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("home, topics, aliases, tags, more and search generate slug-first destinations and serve refreshed slug URLs", async () => {
|
|
const savedHome = homePayload;
|
|
try {
|
|
strictContract = true;
|
|
const drama = { ...fixture, slug: "city-story" },
|
|
topic = { id: 7, slug: "urban-topic", name: "都市专题" };
|
|
homePayload = {
|
|
homeBannerData: [drama],
|
|
homeConfigData: [
|
|
{
|
|
id: 31,
|
|
slug: "editors-choice",
|
|
name: "编辑精选",
|
|
desc: "独立精选介绍",
|
|
status: true,
|
|
type: 1,
|
|
list: [drama],
|
|
},
|
|
{
|
|
id: 32,
|
|
slug: "collections",
|
|
name: "专题合集",
|
|
desc: "专题介绍",
|
|
status: true,
|
|
type: 3,
|
|
themeList: [topic],
|
|
},
|
|
],
|
|
};
|
|
listPayload = (endpoint) =>
|
|
endpoint === "playlet/topic/list"
|
|
? { list: [topic], totalCount: 1 }
|
|
: {
|
|
id: 7,
|
|
slug: "urban-topic",
|
|
name: "都市专题",
|
|
desc: "专题内容介绍",
|
|
list: [drama],
|
|
totalCount: 72,
|
|
};
|
|
tagOptionsPayload = { list: [{ id: 9, slug: "urban", name: "都市" }] };
|
|
for (const path of [
|
|
"/",
|
|
"/topics/",
|
|
"/topics/urban-topic/",
|
|
"/theme/urban-topic/",
|
|
"/topics/urban-topic/page/2/",
|
|
"/tag/urban/",
|
|
"/tag/urban/page/2/",
|
|
"/config/editors-choice/",
|
|
]) {
|
|
const start = calls.length,
|
|
response = await fetch(base + path, { redirect: "manual" }),
|
|
html = await response.text();
|
|
assert.equal(response.status, 200, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
if (path === "/")
|
|
for (const href of [
|
|
"/detail/city-story/",
|
|
"/topics/urban-topic/",
|
|
"/config/editors-choice/",
|
|
])
|
|
assert.ok(html.includes(`href="${href}"`));
|
|
if (path.startsWith("/topics/urban") || path.startsWith("/theme/")) {
|
|
assert.ok(
|
|
html.includes(
|
|
`rel="canonical" href="https://default.drama.test${path}"`,
|
|
),
|
|
);
|
|
const query = new URLSearchParams(
|
|
calls
|
|
.slice(start)
|
|
.find(
|
|
(call) =>
|
|
call.path ===
|
|
"/api/app/" +
|
|
(path.startsWith("/theme/")
|
|
? "playlet/all/list"
|
|
: "playlet/topic/detail"),
|
|
).search,
|
|
);
|
|
assert.equal(
|
|
query.get(path.startsWith("/theme/") ? "themeSlug" : "topicSlug"),
|
|
"urban-topic",
|
|
);
|
|
assert.equal(query.has("id"), false);
|
|
}
|
|
if (path.startsWith("/tag/"))
|
|
assert.equal(
|
|
new URLSearchParams(
|
|
calls
|
|
.slice(start)
|
|
.find((call) => call.path === "/api/app/playlet/tag/list").search,
|
|
).get("tagSlug"),
|
|
"urban",
|
|
);
|
|
if (path === "/config/editors-choice/")
|
|
assert.ok(html.includes('href="/detail/city-story/"'));
|
|
}
|
|
searchPayload = { list: [drama], totalCount: 1, pageSize: 25 };
|
|
const html = await (await fetch(base + "/search/city/")).text();
|
|
assert.ok(
|
|
currentMain(html, "search-results-page").includes(
|
|
'href="/detail/city-story/"',
|
|
),
|
|
);
|
|
} finally {
|
|
homePayload = savedHome;
|
|
strictContract = false;
|
|
listPayload = tagOptionsPayload = searchPayload = undefined;
|
|
}
|
|
});
|
|
|
|
test("missing slugs stay real 404 responses and topic directory outages stay server errors", async () => {
|
|
try {
|
|
detailSeo = { slug: "known-story" };
|
|
listPayload = { list: [{ id: 7, slug: "known-topic" }], totalCount: 1 };
|
|
for (const path of [
|
|
"/detail/unknown-story/",
|
|
"/play/unknown-story/90001/",
|
|
"/topics/unknown-topic/",
|
|
"/tag/unknown-tag/",
|
|
"/config/unknown-config/",
|
|
]) {
|
|
const response = await fetch(base + path, { redirect: "manual" }),
|
|
html = await response.text();
|
|
assert.equal(response.status, 404, path);
|
|
assert.equal(response.headers.get("location"), null);
|
|
assert.ok(!html.includes('rel="canonical"'));
|
|
assert.match(html, /<meta name="robots" content="noindex/);
|
|
}
|
|
responseOverrides = { "playlet/topic/detail": { status: 503, code: 503 } };
|
|
const response = await fetch(base + "/topics/known-topic/");
|
|
assert.equal(response.status, 500);
|
|
assert.match(await response.text(), /<meta name="robots" content="noindex/);
|
|
} finally {
|
|
detailSeo = listPayload = undefined;
|
|
responseOverrides = {};
|
|
}
|
|
});
|
|
|
|
test("detail, episode, chapter list and recommendations are requested before detail finishes", async () => {
|
|
let release;
|
|
blockedDetail = { promise: new Promise((resolve) => (release = resolve)) };
|
|
const start = calls.length;
|
|
const pending = fetch(base + "/play/23456/90001/");
|
|
try {
|
|
const paths = [
|
|
"playlet/detail",
|
|
"playlet/chapter/detail",
|
|
"playlet/chapter/list",
|
|
"playlet/guess-like/list",
|
|
].map((path) => "/api/app/" + path);
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (
|
|
paths.every((path) =>
|
|
calls.slice(start).some((call) => call.path === path),
|
|
)
|
|
)
|
|
break;
|
|
await delay(10);
|
|
}
|
|
for (const path of paths)
|
|
assert.ok(
|
|
calls.slice(start).some((call) => call.path === path),
|
|
path + " must not wait for detail",
|
|
);
|
|
} finally {
|
|
release();
|
|
blockedDetail = undefined;
|
|
}
|
|
const html = await (await pending).text();
|
|
assert.ok(currentMain(html, "drama-play-page").includes("play-detail-title"));
|
|
assert.ok(!calls.slice(start).some((call) => call.path === "/api/app/comment/list"));
|
|
});
|
|
|
|
test("public payloads are reused across page requests while episode permissions stay uncached", async () => {
|
|
await stopNext();
|
|
await startNext({ PUBLIC_DATA_CACHE_TTL_MS: "60000" });
|
|
const previousHeader = headerPayload;
|
|
try {
|
|
seoConfig = {
|
|
reportSdkConfig: "private-shared-sdk-cipher",
|
|
config: { secret: "private-shared-config-secret" },
|
|
};
|
|
footerPayload = {
|
|
footerInfo: {
|
|
brand: { content: "真实直出页脚" },
|
|
contactList: [{ name: "服务时间", value: "每天" }],
|
|
token: "private-footer-token",
|
|
},
|
|
};
|
|
const start = calls.length;
|
|
const pages = await Promise.all(
|
|
["/shorts/", "/topics/", "/home-road/"].map(async (path) =>
|
|
(await fetch(base + path)).text(),
|
|
),
|
|
);
|
|
for (const path of [
|
|
"config/info",
|
|
"config/footer/info",
|
|
"navigation/header",
|
|
"translation/category/list",
|
|
])
|
|
assert.equal(
|
|
calls.slice(start).filter((call) => call.path === "/api/app/" + path)
|
|
.length,
|
|
1,
|
|
path,
|
|
);
|
|
for (const html of pages) {
|
|
assert.ok(html.includes("真实直出页脚"));
|
|
for (const secret of [
|
|
"private-shared-sdk-cipher",
|
|
"private-shared-config-secret",
|
|
"private-footer-token",
|
|
])
|
|
assert.ok(!html.includes(secret));
|
|
}
|
|
await (await fetch(base)).text();
|
|
await (await fetch(base)).text();
|
|
assert.equal(
|
|
calls.slice(start).filter((call) => call.path === "/api/app/playlet/home")
|
|
.length,
|
|
1,
|
|
);
|
|
headerPayload = { categoryList: [] };
|
|
assert.ok(
|
|
navigationHtml(
|
|
await (await fetch(base + "/shorts/")).text(),
|
|
"top",
|
|
).includes("短剧"),
|
|
"public navigation remains valid within TTL",
|
|
);
|
|
const permissionStart = calls.length;
|
|
await (await fetch(base + "/play/23456/90001/")).text();
|
|
await (await fetch(base + "/play/23456/90001/")).text();
|
|
for (const path of ["playlet/detail", "playlet/chapter/detail"])
|
|
assert.equal(
|
|
calls
|
|
.slice(permissionStart)
|
|
.filter((call) => call.path === "/api/app/" + path).length,
|
|
2,
|
|
path + " must stay realtime",
|
|
);
|
|
} finally {
|
|
seoConfig = {};
|
|
footerPayload = undefined;
|
|
headerPayload = previousHeader;
|
|
await stopNext();
|
|
await startNext();
|
|
}
|
|
});
|
|
|
|
test("current-origin ingress separates direct Go responses from Next session cookies while serving SSR and media", async () => {
|
|
await stopNext();
|
|
await startNext({ UPSTREAM_API_URL: "current" });
|
|
// Emulate Nginx: preserve the visitor Host and route only backend paths to Go.
|
|
const ingress = createServer((request, response) => {
|
|
const backend = /^\/(?:api\/app\/|h5\/|(?:new)?enkey(?:\?|\/|$))/.test(
|
|
request.url,
|
|
);
|
|
const target = new URL(request.url, backend ? mockOrigin : base);
|
|
const forwarded = httpRequest(
|
|
target,
|
|
{
|
|
method: request.method,
|
|
headers: { ...request.headers, "x-forwarded-proto": "http" },
|
|
},
|
|
(result) => {
|
|
response.writeHead(result.statusCode, result.headers);
|
|
result.pipe(response);
|
|
},
|
|
);
|
|
forwarded.on("error", () => {
|
|
response.writeHead(502);
|
|
response.end();
|
|
});
|
|
request.pipe(forwarded);
|
|
});
|
|
const origin = "http://127.0.0.1:" + (await listen(ingress));
|
|
try {
|
|
const start = calls.length;
|
|
const homepage = await fetch(origin + "/"),
|
|
html = await homepage.text();
|
|
assert.equal(homepage.status, 200);
|
|
assert.ok(html.includes("后台配置标题"));
|
|
// Direct-Go ingress + jha-1048: HTML SSR must not invent a guest token.
|
|
assert.ok(
|
|
!calls.slice(start).some((call) => call.path === "/api/app/auth/guest"),
|
|
);
|
|
assert.ok(
|
|
calls.slice(start).some((call) => call.path === "/api/app/playlet/home"),
|
|
);
|
|
const config = await fetch(origin + "/api/app/config/info");
|
|
assert.equal(config.status, 200);
|
|
assert.equal((await config.json()).code, 200);
|
|
// This ingress sends /api/app/* straight to Go, bypassing Next's cookie handling.
|
|
const directSession = await fetch(origin + "/api/app/auth/guest", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Origin: origin },
|
|
body: "{}",
|
|
});
|
|
assert.equal(directSession.status, 200);
|
|
assert.equal(directSession.headers.get("set-cookie"), null);
|
|
const directPayload = await directSession.json();
|
|
assert.equal(directPayload.code, 200);
|
|
assert.equal(directPayload.data.token, "test-session-secret");
|
|
// The compatibility alias reaches Next, which converts the token into an HttpOnly cookie.
|
|
const session = await fetch(origin + "/api/auth/guest", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Origin: origin },
|
|
body: "{}",
|
|
});
|
|
assert.equal(session.status, 200);
|
|
assert.match(session.headers.get("set-cookie"), /drama_session=/);
|
|
const sessionPayload = await session.json();
|
|
assert.equal(sessionPayload.code, 200);
|
|
assert.equal(sessionPayload.data.token, undefined);
|
|
const sessionPage = await fetch(origin + "/detail/23456/", {
|
|
headers: {
|
|
Cookie: "drama_session=registered-user-a-secret; drama_registered=1",
|
|
},
|
|
});
|
|
assert.equal(sessionPage.status, 200);
|
|
const sessionHtml = await sessionPage.text();
|
|
assert.ok(currentMain(sessionHtml, "drama-play-page").includes("play-detail-title"));
|
|
assert.ok(!sessionHtml.includes("registered-user-a-secret"));
|
|
assert.equal(calls.at(-1).cookie, undefined);
|
|
const key = await fetch(origin + "/enkey?id=12700&ticket=a%2Fb+1");
|
|
assert.equal(key.status, 200);
|
|
assert.equal((await key.arrayBuffer()).byteLength, 16);
|
|
assert.equal(calls.at(-1).search, "?id=12700&ticket=a%2Fb+1");
|
|
const media = await fetch(origin + "/h5/m3u8/video.m3u8?md=signed+value");
|
|
assert.equal(media.status, 200);
|
|
assert.match(await media.text(), /#EXTM3U/);
|
|
// A direct Node URL has no Go forwarding. It must fail once, rather than recurse.
|
|
const loop = await fetch(base + "/api/app/config/info");
|
|
assert.equal(loop.status, 502);
|
|
assert.match((await loop.json()).msg, /转发配置/);
|
|
const keyLoop = await fetch(base + "/enkey?id=12700&ticket=original");
|
|
assert.equal(keyLoop.status, 502);
|
|
} finally {
|
|
await new Promise((resolve) => ingress.close(resolve));
|
|
await stopNext();
|
|
await startNext();
|
|
}
|
|
});
|