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: "接口剧名 ", 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( /]*\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("Error"); 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(/([\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(!/]*data-site-json-ld=/.test(html)); assert.match(head, /关于我们 - 禁果短剧<\/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>官方入口 - 禁果短剧")); assert.ok( html.includes('
'), ); assert.ok(!html.includes(' 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, / 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-contact", "entry-download", ]) assert.ok(!html.includes(`class="${className}"`)); for (const label of ["官方TG群", "官方推特"]) assert.ok(!html.includes(label)); assert.ok(html.includes('href="/home-road/"')); assert.ok(html.includes('href="/apps/"')); assert.ok(html.includes("grid-template-columns:repeat(2, minmax(0, 1fr))")); } finally { seoConfig = {}; downloadAddress = saved; } }); test("sidebar and footer render configured contacts and omit missing or unsafe channels in initial HTML", 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(`
]*>([\\s\\S]*?)
`), )?.[1]; assert.notEqual(channels, undefined, className); if (configured) for (const url of [ "https://t.me/jgdj_contact", "https://t.me/jgdj_group", "https://x.com/jgdj_channel", ]) assert.ok(channels.includes(`href="${url}"`), className); else assert.doesNotMatch(channels, / { 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/", "/apps/", "/home-road/", "/topics/3/", "/theme/8/", "/tag/9/", "/config/31/", "/detail/23456/", "/play/23456/90001/", "/search/" + encodeURIComponent("都市 / 爱情?#%") + "/", "/member/center/", "/member/favorites/", "/member/history/", "/member/likes/", "/member/purchased/", "/member/settings/", "/info/deposit/", "/info/deposit/vip/", "/info/user-center/", "/info/settings/", "/info/about-us/", "/info/user-agreement/", "/info/privacy-policy/", "/info/help-center/", "/info/2257/", "/info/dmca/", "/info/feedback/", ]; 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(/ { const keyword = encodeURIComponent("都市 / 爱情?#% example.com"); const paths = [ "/navigation", "/search", "/search/" + keyword, "/search/" + keyword + "/page/2", "/rank/hot", "/rank/new", "/rank/follow", "/info/deposit/vip", "/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", "常见问题", "
"], ["2257", "2257 合规声明", "资料留存"], ["dmca", "DMCA 版权声明", "版权通知及反通知请通过本站"], ]) { 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} - 禁果短剧`)); assert.ok(html.includes(marker), key); const main = html.match(/]*\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: "

品牌正文

富文本段落

", token: "private-richtext-token", }, ]; const home = await (await fetch(base + "/")).text(); const links = home.match( /", 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(" call.path === "/api/app/navigation/header").length, 1, ); headerPayload = { categoryList: [] }; const empty = await (await fetch(base + "/shorts/")).text(); assert.ok(!navigationHtml(empty, "top").includes("
{ 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(" { try { appsPayload = { categoryList: [ { id: 80, title: "接口应用分类", appList: [ { id: 81, name: "接口应用", url: "https://apps.example/download", logo: "https://apps.example/logo.webp", }, ], }, ], }; for (const [path, panel, marker] of [ ["/apps/", "channel-page", "接口应用分类"], ["/home-road/", "channel-page", "return-home-directory"], ["/info/feedback/", "info-page", 'id="feedback-form"'], ["/info/about-us/", "info-page", "https://example.com/contact"], ]) { const start = calls.length, 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); if (path === "/apps/") assert.equal( calls .slice(start) .filter((call) => call.path === "/api/app/applist/list").length, 1, ); } } finally { appsPayload = undefined; } }); test("fresh private pages render real guest records with the same persisted identity and no credentials in HTML", async () => { try { accountListPayload = { list: [ { ...fixture, title: "游客自己的历史", password: "private-list-password", }, ], totalCount: 72, }; const start = calls.length, response = await fetch(base + "/member/history/page/2/", { headers: { Cookie: "drama_bearer=test-session-secret" }, }), html = await response.text(), main = currentMain(html, "info-page"); assert.equal(response.status, 200); assert.match(response.headers.get("cache-control"), /no-store/); assert.ok(main.includes("游客自己的历史")); assert.ok(main.includes("接口昵称") || main.includes("真实接口用户")); assert.ok(main.includes('href="/member/history/page/3/"')); assert.ok(!/api-loading|正在加载|加载中/.test(main)); for (const secret of ["test-session-secret", "private-list-password"]) assert.ok(!html.includes(secret)); const reads = calls.slice(start); assert.equal( reads.filter((call) => call.path === "/api/app/auth/guest").length, 0, ); assert.ok( reads.some((call) => call.path === "/api/app/auth/getInfo"), ); assert.equal( reads.find((call) => call.path === "/api/app/playlet/history/list").auth, "test-session-secret", ); } finally { accountListPayload = undefined; } }); test("likes pages render the current account list, native links and pagination before JavaScript without shared caching", async () => { try { strictContract = true; accountListPayload = (path, request) => ({ list: [ { ...fixture, slug: "liked-story", title: request.headers.authorization === "registered-user-a-secret" ? "甲的点赞短剧" : "乙的点赞短剧", token: "private-liked-token", }, ], totalCount: 72, }); for (const [token, title, other] of [ ["registered-user-a-secret", "甲的点赞短剧", "乙的点赞短剧"], ["registered-user-b-secret", "乙的点赞短剧", "甲的点赞短剧"], ]) { const start = calls.length; const response = await fetch(base + "/member/likes/page/2/", { headers: { Cookie: "drama_session=" + token + "; drama_registered=1", }, }), html = await response.text(), main = currentMain(html, "info-page"); assert.equal(response.status, 200); assert.match(response.headers.get("cache-control"), /private.*no-store/); assert.match(html, /]*>我的点赞<\/h1>/); assert.ok(main.includes(title)); assert.ok(!main.includes(other)); assert.ok(main.includes('href="/member/likes/" aria-current="page"')); assert.ok(main.includes('href="/member/likes/page/3/"')); assert.ok(main.includes('href="/detail/liked-story/"')); assert.ok(main.includes("共 72 部短剧")); assert.ok(!/api-loading|正在加载|加载中/.test(main)); for (const secret of [token, "private-liked-token"]) assert.ok(!html.includes(secret)); const reads = calls .slice(start) .filter((call) => call.path === "/api/app/playlet/like/list"); assert.equal(reads.length, 1); assert.equal(reads[0].auth, token); assert.equal(reads[0].cookie, undefined); assert.equal(new URLSearchParams(reads[0].search).get("pageNum"), "2"); assert.equal(new URLSearchParams(reads[0].search).get("pageSize"), "24"); } const response = await fetch( base + "/api/app/playlet/like/list?pageNum=3&pageSize=24", { headers: { Cookie: "drama_session=registered-user-a-secret; drama_registered=1", }, }, ); assert.equal(response.status, 200); assert.match(response.headers.get("cache-control"), /no-store/); assert.equal((await response.json()).code, 200); assert.equal(calls.at(-1).auth, "registered-user-a-secret"); } finally { accountListPayload = undefined; strictContract = false; } }); test("likes distinguish empty records, out-of-range pages, backend failures and expired accounts", async () => { const headers = { Cookie: "drama_session=registered-user-a-secret; drama_registered=1", }; try { accountListPayload = { list: [], totalCount: 0 }; const emptyResponse = await fetch(base + "/member/likes/", { headers }), emptyHtml = await emptyResponse.text(); assert.equal(emptyResponse.status, 200); assert.ok(emptyHtml.includes("暂无点赞的短剧")); assert.ok(emptyHtml.includes('href="/shorts/">去发现好剧')); assert.equal( (await fetch(base + "/member/likes/page/2/", { headers })).status, 404, ); responseOverrides = { "playlet/like/list": { status: 503, code: 503 } }; const failed = await fetch(base + "/member/likes/", { headers }), failedHtml = await failed.text(); assert.equal(failed.status, 500); assert.match(failedHtml, / ["/api/app/auth/guest", "/api/app/playlet/like/list"].includes( call.path, ), ), ); } finally { accountListPayload = undefined; responseOverrides = {}; } }); test("private settings isolate two users and ignore forged SSR identity headers", async () => { const start = calls.length; for (const [token, name, other] of [ ["registered-user-a-secret", "用户甲", "用户乙"], ["registered-user-b-secret", "用户乙", "用户甲"], ]) { const forged = Buffer.from( JSON.stringify({ userId: 999, nickName: "伪造管理员" }), ).toString("base64url"); const response = await fetch(base + "/member/settings/", { headers: { Cookie: "drama_session=" + token + "; drama_registered=1", "x-drama-ssr-session": "ready", "x-drama-ssr-user": forged, }, }); const html = await response.text(), main = currentMain(html, "info-page"); assert.ok(main.includes(name)); assert.ok(!html.includes(other)); assert.ok(!html.includes("伪造管理员")); for (const secret of [token, "must-not-leak"]) assert.ok(!html.includes(secret)); assert.ok(!main.includes("api-loading")); } assert.equal( calls.slice(start).filter((call) => call.path === "/api/app/auth/getInfo") .length, 2, ); assert.ok( !calls.slice(start).some((call) => call.path === "/api/app/auth/guest"), ); }); test("registered expiry renders sign-in immediately without guest downgrade", async () => { const start = calls.length, response = await fetch(base + "/member/settings/", { headers: { Cookie: "drama_session=expired; drama_registered=1" }, }), html = await response.text(), main = currentMain(html, "info-page"); assert.ok(main.includes("请先登录账号")); assert.ok(main.includes('data-auth-mode="login"')); assert.ok(!main.includes('id="profile-form"')); assert.ok(!main.includes("api-loading")); assert.ok( !calls.slice(start).some((call) => call.path === "/api/app/auth/guest"), ); }); test("wallet SSR contains configured packages and transactions without creating an order or leaking extra fields", async () => { try { packagesPayload = { coinList: [ { id: 91, name: "真实金币套餐", price: 1000, baseCoin: 100, giftCoin: 5, totalCoin: 105, packageType: 2, payMethods: [{ type: "alipay", typeName: "支付宝" }], token: "private-package-token", }, ], vipList: [], }; transactionsPayload = { list: [ { title: "真实交易明细", changeAmount: 25, createdAt: "2026-10-21 10:30:00", token: "private-transaction-token", }, ], totalCount: 18, }; const start = calls.length, response = await fetch(base + "/info/deposit/", { headers: { Cookie: "drama_bearer=test-session-secret" }, }), html = await response.text(), main = currentMain(html, "info-page"); assert.ok(main, "main info-page must exist"); assert.ok(main.includes("100金币")); assert.ok(main.includes("真实交易明细")); assert.ok(main.includes('href="/info/deposit/page/2/"')); assert.ok( !calls.slice(start).some((call) => call.path === "/api/app/auth/guest"), ); assert.ok(!/api-loading|正在加载|加载中/.test(main)); for (const secret of [ "private-package-token", "private-transaction-token", "test-session-secret", ]) assert.ok(!html.includes(secret)); const reads = calls.slice(start); assert.equal( reads.filter((call) => call.path === "/api/app/recharge/package/list") .length, 1, ); assert.equal( reads.filter((call) => call.path === "/api/app/coin/trans/list").length, 1, ); assert.ok( !reads.some((call) => [ "/api/app/coin/buy", "/api/app/vip/pay", "/api/app/playlet/chapter/unlock", ].includes(call.path), ), ); } finally { packagesPayload = undefined; transactionsPayload = undefined; } }); 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('
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", ); const comments = new URLSearchParams( reads.find((call) => call.path === "/api/app/comment/list").search, ); assert.equal(comments.get("objectId"), "23456"); assert.equal(comments.get("objectType"), "10"); if (chapter) { assert.ok(main.includes('href="/tag/urban/"')); assert.ok(main.includes('href="/detail/city-story/"')); } const graph = JSON.parse( html.match( /]*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, / { 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", "comment/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")); }); test("public payloads are reused across page requests while two accounts and 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 privateStart = calls.length; for (const [token, name, other] of [ ["registered-user-a-secret", "用户甲", "用户乙"], ["registered-user-b-secret", "用户乙", "用户甲"], ]) { const html = await ( await fetch(base + "/member/settings/", { headers: { Cookie: "drama_session=" + token + "; drama_registered=1", }, }) ).text(); assert.ok(currentMain(html, "info-page").includes(name)); assert.ok(!html.includes(other)); } assert.equal( calls .slice(privateStart) .filter((call) => call.path === "/api/app/auth/getInfo").length, 2, ); 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 privatePage = await fetch(origin + "/member/settings/", { headers: { Cookie: "drama_session=registered-user-a-secret; drama_registered=1", }, }); assert.equal(privatePage.status, 200); assert.ok( currentMain(await privatePage.text(), "info-page").includes("用户甲"), ); 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(); } });