import {test} from 'node:test'; import assert from 'node:assert/strict'; import {isIP} from 'node:net'; import {readFile} from 'node:fs/promises'; import vm from 'node:vm'; import ts from 'typescript'; const environment={PUBLIC_DATA_CACHE_TTL_MS:'60000'}; async function compile(path,dependencies={},extra={}) { const exports={}; const {outputText}=ts.transpileModule(await readFile(new URL('../src/lib/api/'+path+'.ts',import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}); vm.runInNewContext(outputText,{exports,URL,Headers,Request,Response,FormData,AbortSignal,TextDecoder,structuredClone,process:{env:environment},require:name=>{ if(!(name in dependencies))throw new Error('Unknown dependency: '+name); return dependencies[name]; },...extra}); return exports; } const config=await compile('config'); const protocol=await compile('protocol'); const cache=await compile('public-cache'); test('development uses its configured domain and supports legacy overrides',()=>{ delete environment.UPSTREAM_API_URL;delete environment.API_BASE_URL; assert.equal(config.getApiBaseUrl(),'http://172.104.181.99:21022'); environment.APP_ENV='test';assert.equal(config.getApiBaseUrl(),'http://172.104.181.99:21022'); environment.APP_ENV='production';assert.equal(config.getApiBaseUrl(),'http://172.104.56.28:26125'); delete environment.APP_ENV; environment.API_BASE_URL='https://legacy-api.example/';assert.equal(config.getApiBaseUrl(),'https://legacy-api.example'); environment.UPSTREAM_API_URL=' http://127.0.0.1:21111/ '; assert.equal(config.getApiBaseUrl(),'http://127.0.0.1:21111'); for(const value of ['', 'javascript:bad', 'https://user:secret@api.example', 'https://api.example/?query=1', 'https://api.example/#fragment']){ environment.UPSTREAM_API_URL=value;assert.throws(()=>config.getApiBaseUrl(),/UPSTREAM_API_URL/); } }); test('explicit current mode resolves the current domain per request, including protocol and port',()=>{ environment.UPSTREAM_API_URL='current';delete environment.API_BASE_URL; for(const APP_ENV of ['test','production']){ environment.APP_ENV=APP_ENV; assert.equal(config.usesCurrentApiOrigin(),true); assert.equal(config.getApiBaseUrl({url:'http://0.0.0.0:21021/api/config/info',headers:new Headers({Host:'first.example:8443','X-Forwarded-Proto':'https','X-Forwarded-Host':'ignored.example'})}),'https://first.example:8443'); assert.equal(config.getApiBaseUrl({url:'http://second.example:21021/api/config/info',headers:new Headers()}),'http://second.example:21021'); assert.equal(config.getApiBaseUrl({headers:new Headers({'X-Forwarded-Host':'fallback.example','X-Forwarded-Proto':'https'})}),'https://fallback.example'); assert.throws(()=>config.getApiBaseUrl(),/请求域名/); for(const Host of ['user:secret@api.test','api.test/path','api.test?x=1','api.test,other.test','api.test\\other']){ assert.throws(()=>config.getApiBaseUrl({headers:new Headers({Host})})); } assert.throws(()=>config.getApiBaseUrl({headers:new Headers({Host:'api.test','X-Forwarded-Proto':'ftp'})})); } delete environment.APP_ENV; }); test('SSR and API proxy share the configured upstream while cache entries stay isolated by backend',async()=>{ const requests=[]; const server=await compile('server',{'./protocol':protocol,'./config':config,'./public-cache':cache,react:{cache:fn=>fn},'next/headers':{headers:()=>{throw new Error('Fixed upstream must not read request headers');}}},{fetch:async(url,options)=>{ requests.push({url:String(url),...options});return Response.json({code:200,data:{source:new URL(url).origin}}); }}); environment.UPSTREAM_API_URL='https://api-a.example'; const first=await server.upstream('config/info');assert.equal(first.data.source,'https://api-a.example'); environment.UPSTREAM_API_URL='https://api-b.example'; assert.equal((await server.upstream('config/info')).data.source,'https://api-b.example'); environment.UPSTREAM_API_URL='https://api-a.example'; assert.equal((await server.upstream('config/info')).data.source,'https://api-a.example');assert.equal(requests.length,2); await server.upstream('auth/getInfo',{token:'server-only-token'}); assert.equal(requests.at(-1).url,'https://api-a.example/api/app/auth/getInfo');assert.equal(requests.at(-1).headers.Authorization,'server-only-token'); }); test('SSR, API handlers and private requests use the current origin without mixing public caches',async()=>{ environment.UPSTREAM_API_URL='current'; let incoming=new Headers({Host:'site-a.example','X-Forwarded-Proto':'https'}); const requests=[]; const server=await compile('server',{'./protocol':protocol,'./config':config,'./public-cache':cache,react:{cache:fn=>fn},'next/headers':{headers:async()=>incoming}},{fetch:async(url,options)=>{ requests.push({url:String(url),...options});return Response.json({code:200,data:{source:new URL(url).origin}}); }}); assert.equal((await server.upstream('config/info')).data.source,'https://site-a.example'); incoming=new Headers({Host:'site-b.example','X-Forwarded-Proto':'http'}); assert.equal((await server.upstream('config/info')).data.source,'http://site-b.example'); const request=new Request('https://site-a.example/api/config/info'); assert.equal((await server.upstream('config/info',{request})).data.source,'https://site-a.example'); assert.equal(requests.length,2); await server.upstream('auth/getInfo',{request,token:'private-current-token'}); assert.equal(requests.at(-1).url,'https://site-a.example/api/app/auth/getInfo'); assert.equal(requests.at(-1).headers.Authorization,'private-current-token'); assert.equal(requests.at(-1).headers[config.UPSTREAM_HOP_HEADER],'1'); }); test('current-origin media preserves signatures and fails immediately if the upstream returns to Next',async()=>{ environment.UPSTREAM_API_URL='current'; const requests=[]; const media=await compile('media',{'node:net':{isIP},'./config':config},{fetch:async(url,options)=>{ requests.push({url:String(url),...options});return new Response(new Uint8Array(16)); }}); const request=new Request('http://0.0.0.0:21021/enkey?id=1&ticket=a%2Fb+1',{headers:{Host:'deployed.example','X-Forwarded-Proto':'https',Cookie:'drama_session=secret'}}); assert.equal((await media.proxyMedia(request,'/enkey',true)).status,200); assert.equal(requests[0].url,'https://deployed.example/enkey?id=1&ticket=a%2Fb+1'); assert.equal(requests[0].headers.get('cookie'),null); assert.equal(requests[0].headers.get(config.UPSTREAM_HOP_HEADER),'1'); const loop=new Request('https://deployed.example/enkey',{headers:{[config.UPSTREAM_HOP_HEADER]:'1'}}); const response=await media.proxyMedia(loop,'/enkey',true); assert.equal(response.status,502);assert.match(await response.text(),/转发配置/);assert.equal(requests.length,1); }); test('media and keys use the configured upstream while preserving signatures, byte ranges and credential isolation',async()=>{ const reads=[]; const media=await compile('media',{'node:net':{isIP},'./config':config},{fetch:async(url,options)=>{ reads.push({url:String(url),headers:options.headers}); return new Response(new URL(url).pathname==='/enkey/'?new Uint8Array(16):'#EXTM3U\n#EXT-X-ENDLIST\n'); }}); environment.UPSTREAM_API_URL='http://127.0.0.1:21111'; const incoming=new Request('https://current-site.example/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2',{headers:{Range:'bytes=0-2',Cookie:'drama_session=secret',Authorization:'secret','X-Real-IP':'203.0.113.4'}}); assert.equal((await media.proxyMedia(incoming,'/h5/m3u8/video.m3u8')).status,200); assert.equal(reads.at(-1).url,'http://127.0.0.1:21111/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2'); assert.equal(reads.at(-1).headers.get('range'),'bytes=0-2');assert.equal(reads.at(-1).headers.get('x-real-ip'),'203.0.113.4');assert.equal(reads.at(-1).headers.get('x-forwarded-for'),null); assert.equal(reads.at(-1).headers.get('authorization'),null);assert.equal(reads.at(-1).headers.get('cookie'),null); const key=await media.proxyMedia(new Request('https://current-site.example/enkey?token=a%2Fb'),'/enkey/',true); assert.equal(key.status,200);assert.equal((await key.arrayBuffer()).byteLength,16);assert.equal(reads.at(-1).url,'http://127.0.0.1:21111/enkey/?token=a%2Fb'); }); test('key proxies follow one same-origin slash change in either direction without changing signed queries or headers',async()=>{ environment.UPSTREAM_API_URL='https://key-api.example'; const query='?id=12700&t=123&ticket=a%2Fb+1&ticket=x%252Fy'; for(const pathname of ['/enkey','/enkey/','/newenkey','/newenkey/']){ const alternate=pathname.endsWith('/')?pathname.slice(0,-1):pathname+'/'; const reads=[]; const media=await compile('media',{'node:net':{isIP},'./config':config},{fetch:async(url,options)=>{ reads.push({url:String(url),...options}); return reads.length===1?new Response('',{status:301,headers:{Location:alternate+(pathname.endsWith('/')?'':query)}}):new Response(new Uint8Array(16)); }}); const result=await media.proxyMedia(new Request('http://local.test'+pathname+query,{headers:{'X-Real-IP':'203.0.113.7','User-Agent':'key-test',Cookie:'drama_session=private',Authorization:'private'}}),pathname,true); assert.equal(result.status,200);assert.equal((await result.arrayBuffer()).byteLength,16); assert.deepEqual(reads.map(item=>item.url),['https://key-api.example'+pathname+query,'https://key-api.example'+alternate+query]); for(const read of reads){ assert.equal(read.redirect,'manual');assert.equal(read.cache,'no-store'); assert.equal(read.headers.get('X-Real-IP'),'203.0.113.7');assert.equal(read.headers.get('User-Agent'),'key-test'); assert.equal(read.headers.get('Cookie'),null);assert.equal(read.headers.get('Authorization'),null); } assert.equal(reads[0].signal,reads[1].signal); } }); test('key proxies reject unrelated, cross-origin and repeated redirects before forwarding credentials or signatures',async()=>{ environment.UPSTREAM_API_URL='https://key-api.example'; for(const [location,repeated] of [['https://external.example/enkey/',false],['/newenkey/',false],['/enkey/?ticket=changed',false],['https://user:secret@key-api.example/enkey/',false],['/enkey/',true],[null,false]]){ const reads=[]; const media=await compile('media',{'node:net':{isIP},'./config':config},{fetch:async(url)=>{ reads.push(String(url)); return new Response('',{status:302,headers:location?{Location:reads.length===1?location:'/enkey'}:{}}); }}); const result=await media.proxyMedia(new Request('http://local.test/enkey?ticket=original'),'/enkey',true); assert.equal(result.status,502,String(location));assert.equal(reads.length,repeated?2:1,String(location)); } });