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:'黄果吃瓜'},...seoConfig,config:{foreverUrlAddr:'https://huangguocg.com',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, only the site domain',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.match(head,/rel="canonical" href="https:\/\/[^"]+\/info\/about-us\/"/);
assert.ok(!head.includes('untrusted.entry.test'));
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('call.path==='/api/app/config/info'&&!call.auth),'only public site configuration is needed');
});
test('robots sitemap always uses the site domain, survives configuration failure and never guesses a host',async()=>{
if(testEnvironment!=='production')return;
try{
seoConfig={config:{foreverUrlAddr:'https://huangguocg.com/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:\/\/huangguocg\.com\/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:\/\/huangguocg\.com\/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:\/\/huangguocg\.com\/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,/]*data-page="home"/);
assert.match(html,/]*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(/