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/91av-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; 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:'91-drama-next'}); 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'],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-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:'91短剧'},...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==='translation/category/list')data={categoryList:categories}; 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/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/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/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,/关于我们 - 91短剧<\/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">91短剧 · 百万大神原创短剧<\/h1>/); assert.match(html,/<h2>接口剧名 <script>alert\(1\)<\/script><\/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="/assets/poster-placeholder.svg"')); 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>官方入口 - 91短剧')); assert.ok(html.includes('
')); assert.ok(!html.includes('call.path),['/api/app/config/info']); 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('动态导航站 · 线路二')call.path),['/api/app/config/info']); }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('/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/','/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','/assets/poster-placeholder.svg','/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 版权声明','mailto:huangguoaiai@gmail.com'], ]){ 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} - 91短剧`));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/auth/guest','/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 or rich text'); assert.ok(html.includes('class="home-content" hidden=""')); } }); 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/','/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} - 91短剧`),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(/([\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,/后台首页标题 - 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.png"/); 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.ok(!/googletagmanager|google-analytics|mc\.yandex/.test(head)); 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+'页':''}`)); assert.ok(head.includes(`1);assert.equal(head.includes('{ try{ requireSeoAuth=true; seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://canonical.drama.test/'}}; detailSeo={seoInfo:{title:'剧集独立标题',description:'真实剧集描述',keywords:'剧集词'}}; const start=calls.length; const response=await fetch(base+'/detail/23456/',{headers:{'X-Real-IP':'203.0.113.8'}});assert.equal(response.status,200); const html=await response.text(),head=html.match(/([\s\S]*?)<\/head>/)[1]; assert.ok(head.includes('剧集独立标题 - 91剧场'));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,1); assert.equal(calls.slice(start).find(call=>call.path==='/api/app/auth/guest').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('data-auth-mode="login"'));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(/([\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(/]*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(/]*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:'剧名 ',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,6); } for(const path of ['/detail/23456/','/play/23456/90001/']){ html=await (await fetch(base+path)).text();graph=graphOf(html); assert.ok(!html.includes(''));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(!/]*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+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/']; 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,basePath==='/config/31/'?404: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/']){ 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('DevID=')); }); test('browser BFF stays separate from Go app routes and preserves sessions, queries and logout',async()=>{ let start=calls.length; const guest=await fetch(base+'/api/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/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/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/not-allowed')).status,404); const logout=await fetch(base+'/api/auth/logout',{method:'POST',headers:{Origin:base,'Content-Type':'application/json'},body:'{}'}); assert.equal(logout.status,200);assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0'))); assert.equal(calls.length,start); }); 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://d94azi0n98az4.cloudfront.net'; const cases=[ {Host:`localhost:${port}`,Origin:`http://localhost:${port}`}, {Host:`127.0.0.1:${port}`,Origin:`http://127.0.0.1:${port}`}, {Host:'d94azi0n98az4.cloudfront.net',Origin:browserOrigin}, {Host:'next.internal:21021',Origin:browserOrigin,'X-Forwarded-Host':'d94azi0n98az4.cloudfront.net','X-Forwarded-Proto':'https'}, {Host:'next.internal:21021',Origin:'null'}, {Host:'next.internal:21021',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://d94azi0n98az4.cloudfront.net','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('login sends device/channel and trimmed credentials without attaching the previous identity',async()=>{ const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json','X-Real-IP':'203.0.113.9',Cookie:'drama_session=old-guest; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({account:' player01 ',password:' Secure123 ',devID:'different',channel:'later'})}); assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.token,undefined); const call=calls.at(-1);assert.equal(call.auth,undefined);assert.deepEqual(JSON.parse(call.body),{account:'player01',password:' Secure123 ',devID:'device-stable',channel:'first'}); assert.ok(call.device.includes('DevID=device-stable;')); assert.equal(call.ip,'203.0.113.9'); assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;'))); }); test('binding retains the guest Authorization, device, channel and invite and fills the optional email',async()=>{ const response=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=old-guest; drama_registered=0; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({accountNo:' player01 ',password:'Secure123',agentInviteCode:' INV123 ',channel:'later'})}); assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,77);assert.equal(data.data.token,undefined); const call=calls.at(-1);assert.equal(call.auth,'old-guest'); assert.deepEqual(JSON.parse(call.body),{accountNo:'player01',password:'Secure123',email:'player01@91dj.local',devID:'device-stable',channel:'first',agentInviteCode:'INV123'}); const memberResponse=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=registered; drama_registered=1'},body:JSON.stringify({accountNo:'player02',password:'Secure123',email:'me@example.com'})}); assert.equal(memberResponse.status,200);assert.equal(calls.at(-1).auth,undefined);assert.equal(JSON.parse(calls.at(-1).body).email,'me@example.com'); }); 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']); assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=test-registered-secret;'))); assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;'))); }); 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('SDK snapshots reach order APIs and the same drama context survives the guest retry',async()=>{ const common={app_id:'1047',uid:'',channel:'organic',sid:'sdk-session',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-sdk-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'}; const sdk=Buffer.from(JSON.stringify(common)).toString('base64'); const requestId='1234567890abcdef1234567890abcdef',context={v:1,request_id:requestId,event_name:'drama_collect',common,drama:{media_id:'',drama_id:'23456',episode:1,video_id:'90001',recommend_trace_id:''}}; 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; drama_device=business-device','X-SDK-Common':sdk,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_collect','X-DC-Request-Context':Buffer.from(JSON.stringify(context)).toString('base64url')},body:'{"id":23456,"isCollect":true}'}); assert.equal(response.status,200); const requests=calls.slice(count);assert.equal(requests.length,3); for(const call of [requests[0],requests[2]]){ assert.equal(call.trackerId,requestId);assert.equal(call.trackerEvent,'drama_collect');assert.deepEqual(JSON.parse(Buffer.from(call.trackerContext,'base64url')),context);assert.equal(call.sdk,sdk); } assert.equal(requests[1].trackerContext,undefined);assert.equal(requests[1].sdk,sdk);assert.equal(JSON.parse(requests[1].body).devID,'business-device'); await fetch(base+'/api/app/coin/buy',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret','X-SDK-Common':sdk},body:'{"packageId":1,"payType":"alipay"}'}); assert.equal(calls.at(-1).sdk,sdk);assert.equal(calls.at(-1).trackerContext,undefined); }); test('bad credentials and malformed successful login responses never overwrite an existing account cookie',async()=>{ for(const [account,status] of [['wrong',200],['missing-token',502]]){ const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=valid-account; drama_registered=1'},body:JSON.stringify({account,password:'Secure123'})}); assert.equal(response.status,status);assert.notEqual((await response.json()).code,200); assert.ok(!response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')||value.startsWith('drama_registered='))); } }); test('manual logout skips the following automatic guest initialization and explicit sign-in can resume',async()=>{ const logout=await fetch(base+'/api/app/auth/logout',{method:'POST',body:'{}'}); assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=1;'))); const count=calls.length; const skipped=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1'},body:'{"automatic":true}'}); assert.equal(skipped.status,200);assert.equal((await skipped.json()).data,null);assert.equal(calls.length,count); assert.ok(skipped.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0'))); const explicit=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1; drama_device=device-stable'},body:'{}'}); assert.equal(explicit.status,200);assert.equal((await explicit.json()).data.userId,77); assert.ok(explicit.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0'))); }); 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('confirmed chapter unlock forwards the exact episode and identity and returns the upstream balance',async()=>{ const count=calls.length; const response=await fetch(base+'/api/app/playlet/chapter/unlock',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret; drama_device=unlock-test-device'},body:JSON.stringify({playletId:23456,chapterId:90001})}); assert.equal(response.status,200);const payload=await response.json();assert.equal(payload.code,200);assert.equal(payload.data.balance,77); assert.equal(calls.length,count+1);const call=calls.at(-1); assert.equal(call.path,'/api/app/playlet/chapter/unlock');assert.equal(call.auth,'test-session-secret'); assert.deepEqual(JSON.parse(call.body),{playletId:23456,chapterId:90001});assert.ok(call.device.includes('DevID=unlock-test-device;')); }); test('media proxy preserves signed URLs and HLS content while isolating application credentials',async()=>{ const query='?token=a%2Fb+1&sig=x%252Fy'; const response=await fetch(base+'/h5/m3u8/folder/movie.m3u8'+query,{redirect:'manual',headers:{Authorization:'private',Cookie:'drama_session=private','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); }); 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); }); test('logout clears cookies without mutating upstream content',async()=>{ const count=calls.length; const response=await fetch(base+'/api/app/auth/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); assert.equal(response.status,200);assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0'))); assert.equal(calls.length,count); }); function currentMain(html,id){ const match=html.match(new RegExp(']*\\bid="'+id+'"[^>]*>[\\s\\S]*?<\\/main>')); assert.ok(match,'main '+id+' must exist');assert.ok(!/^]*\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/all/list'],['/shorts/page/2/','playlet/all/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/topic/detail'],['/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(/([\\s\\S]*?)<\\/section>'))?.[1]; assert.ok(section);assert.equal((section.match(/]*>([\s\S]*?)<\/section>/)?.[1]; assert.ok(shelf);assert.match(shelf,/

/); const detail=currentMain(await (await fetch(base+'/detail/23456/')).text(),'drama-play-page'); assert.match(detail,/