Files
web_hgcg_h5/tests/api-config.test.mjs
2026-10-27 17:39:27 +07:00

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));
}
});