153 lines
11 KiB
JavaScript
153 lines
11 KiB
JavaScript
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(),'https://webhgcgapi.mfcg.me');
|
|
environment.APP_ENV='test';assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
|
|
environment.APP_ENV='production';assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
|
|
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));
|
|
}
|
|
});
|