/**
* 在临时前端项目中编译实际 Next API route,连接本地 HTTP 上游替身,检查真实代理路径。
* 执行本脚本会启动 Next dev 并产生构建文件;不是纯静态检查,也不是远程 Foundry 数据库集成。
* 覆盖冻结字节、认证头、资产元数据/上传、AI 协议与事件游标、流式首块,以及路径 allowlist。
*/
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, copyFile, writeFile, symlink, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises';
// 临时目录只复制代理必需文件,received 保存上游实际收到的请求,避免只验证前端 fetch 参数。
const source=resolve(import.meta.dirname,'../..'),directory=await mkdtemp(join(tmpdir(),'endoleap-proxy-check-')),received=[];
// 测试专用账户和幂等键都是固定假值,不读取真实用户登录凭据。
const account='10000000-0000-4000-8000-000000000004',key='90000000-0000-4000-8000-000000000002';
// 响应特意保留空白和 extension,要求代理把终态回执原字节透传而非重建 JSON。
const responseBytes=' { "protocol_version":2, "request_id":"'+key+'", "status":"not-applied", "terminal":true, "error":"fixture_rejection", "extension":{"retained":true} }\n';
// 流尾由测试释放,首块可先到客户端;用来判断代理是否缓冲了整条上游响应。
let streamRelease;const streamGate=new Promise(resolve=>streamRelease=resolve);
// 仅用于代理传输的最小 NDJSON 片段,不是完整模型解码样本。
const streamPrefix='{"streamVersion":1,"kind":"header"}\n',streamTail='{"streamVersion":1,"kind":"complete"}\n';
// 上游先收集 body 和 headers;模型流先写前缀再等待,其余路径返回可辨识 JSON 或 409 回执。
const upstream=createServer(async(request,response)=>{
const chunks=[];for await(const chunk of request)chunks.push(chunk);
received.push({path:request.url,body:Buffer.concat(chunks),headers:request.headers});
if(request.url.endsWith('/model')&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});response.write(streamPrefix);await streamGate;response.end(streamTail);return;}
response.writeHead(request.url.endsWith('/model/operations')?409:200,{'Content-Type':'application/json'});
response.end(request.url.endsWith('/model/operations')?responseBytes:'{"id":"fixture"}');
});
// 系统分配上游监听端口;另一个短暂监听器只为 Next 选端口,释放后再启动 Next,未构成持续端口锁。
await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve));
const reservation=createServer();await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve));const port=reservation.address().port;await new Promise(resolve=>reservation.close(resolve));
// 保存子进程、有限长度启动日志和 spawn 错误,供 readiness 失败时定位原因。
let next,logs='',launchError;
try{
// 复制实际 route 及它依赖的响应/会话模块;临时页面仅满足 Next 项目结构,不替换被测 route。
for(const filename of ['package.json','tsconfig.json','src/app/api/foundry/[...path]/route.ts','src/lib/canvas-response.ts','src/lib/session-cookie.ts']){
const target=join(directory,filename);await mkdir(resolve(target,'..'),{recursive:true});await copyFile(join(source,filename),target);
}
// 复用当前依赖树而不重新安装,临时项目的源码和编译目录与主项目分开。
await symlink(join(source,'node_modules'),join(directory,'node_modules'));
await writeFile(join(directory,'src/app/layout.tsx'),'export default function Layout({children}:{children:React.ReactNode}){return
{children}}');
await writeFile(join(directory,'src/app/page.tsx'),'export default function Page(){return Proxy verification fixture}');
// 显式运行 webpack 开发服务器并指向本地上游,detached 便于 finally 按进程组清理。
next=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),'dev','--webpack','--hostname','127.0.0.1','--port',String(port)],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'fixture'},stdio:['ignore','pipe','pipe']});
// 只保留最后 12000 字符日志;启动失败和退出必须被 readiness 循环识别。
next.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.on('error',error=>{launchError=error});
// 最多 90 次探测,单次请求超时 1.5 秒、失败间隔 250 毫秒;只有实际代理返回 200 才开始断言。
let ready=false;
for(let attempt=0;attempt<90;attempt++){
if(launchError)throw launchError;if(next.exitCode!==null)throw new Error('Next exited during startup:\n'+logs);
try{const response=await fetch(`http://127.0.0.1:${port}/api/foundry/profile`,{signal:AbortSignal.timeout(1500)});await response.text();if(response.status===200){ready=true;break}}catch{}
await pause(250);
}
if(!ready)throw new Error('Next did not become ready:\n'+logs);
// 规范文档操作路径使用固定 workspace/material,后续从它推导资产及 AI 路由。
const path='workspaces/10000000-0000-4000-8000-000000000003/materials/10000000-0000-4000-8000-000000000001/canvas/model/operations';
// 1.1 MB 请求超过通用 1 MiB 上限但低于规范文档专用 5 MiB 上限,专门覆盖该路由的体积策略。
const body=' {\n "protocol_version":2,"request_id":"'+key+'","fixture":"'+ 'x'.repeat(1100000)+'"\n} ';
// 发送超过通用限制但低于文档专用限制的原始 body,核对上游状态码及终态回执字节不变。
const response=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}`,{method:'POST',body,headers:{'Content-Type':'application/json','Idempotency-Key':key,'X-Expected-Account':account,Cookie:'foundry_session=fixture-session',Origin:`http://127.0.0.1:${port}`},signal:AbortSignal.timeout(20000)});
assert.equal(response.status,409);assert.equal(await response.text(),responseBytes);
// 从上游记录检查 body、幂等键、预期账户,以及 session cookie 转成 Bearer 的实际结果。
const request=received.find(item=>item.path.endsWith('/model/operations'));
assert.ok(request);assert.equal(request.body.toString(),body);assert.equal(request.headers['idempotency-key'],key);assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers.authorization,'Bearer fixture-session');
// 资产回执和元数据是 JSON 路径;元数据还检查身份传递与 no-store,避免误走图片 body 验证。
const receiptPath=`workspaces/10000000-0000-4000-8000-000000000003/assets/operations/${key}`;
const assetReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath}`);assert.equal(assetReceipt.status,200);assert.deepEqual(await assetReceipt.json(),{id:'fixture'});
const metadata=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath.replace('/operations/','/metadata/')}`,{headers:{'X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});assert.equal(metadata.status,200);assert.deepEqual(await metadata.json(),{id:'fixture'});assert.match(metadata.headers.get('cache-control'),/no-store/);const metadataRequest=received.find(item=>item.path.includes('/assets/metadata/'));assert.equal(metadataRequest.headers['x-expected-account'],account);assert.equal(metadataRequest.headers.authorization,'Bearer fixture-session');
console.log('PASS asset metadata JSON uses the actual authenticated Next proxy without image-body validation or caching');
// FormData 自带 boundary;下面核对文件内容片段、Content-Type 和资源/幂等身份头,未逐字节比较整份 multipart 包。
const multipart=new FormData();multipart.append('image',new Blob(['immutable file'],{type:'image/png'}),'fixture.png');
const assetPath=receiptPath.split('/operations/')[0],resourceId='10000000-0000-4000-8000-000000000006';
const uploaded=await fetch(`http://127.0.0.1:${port}/api/foundry/${assetPath}`,{method:'POST',body:multipart,headers:{'X-Resource-Id':resourceId,'Idempotency-Key':key,'X-Expected-Account':account}});await uploaded.text();assert.equal(uploaded.status,200);
const upload=received.find(item=>item.path.endsWith('/assets'));assert.equal(upload.headers['x-resource-id'],resourceId);assert.equal(upload.headers['idempotency-key'],key);assert.match(upload.body.toString(),/immutable file/);assert.match(upload.headers['content-type'],/^multipart\/form-data; boundary=/);
console.log('PASS asset receipt JSON and multipart resource identity cross the actual Next proxy');
// AI 受理同样保留冻结 JSON 空白、幂等键和 If-Match,并协商 canvas v2;查询回执与事件游标单独检查。
const aiPath=path.replace('/model/operations','/ai/runs');
const frozenAI=' {\n "protocol_version":2,"request_id":"'+key+'","prompt":"保留请求字节" } ';
const aiResponse=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}`,{method:'POST',body:frozenAI,headers:{'Content-Type':'application/json','Idempotency-Key':key,'If-Match':'"7"','X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});
await aiResponse.text();assert.equal(aiResponse.status,200);
const ai=received.find(item=>item.path.endsWith('/ai/runs'));
assert.equal(ai.body.toString(),frozenAI);assert.equal(ai.headers['if-match'],'"7"');assert.equal(ai.headers['idempotency-key'],key);assert.equal(ai.headers['x-expected-account'],account);assert.equal(ai.headers.authorization,'Bearer fixture-session');assert.equal(ai.headers.accept,'application/vnd.foundry.canvas+json;version=2');
const aiReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath.replace('/runs','/operations')}/${key}`);await aiReceipt.text();assert.equal(aiReceipt.status,200);
assert.ok(received.some(item=>item.path.endsWith('/ai/operations/'+key)));
const aiEvents=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}/${key}?after_seq=41&limit=100`);await aiEvents.text();assert.equal(aiEvents.status,200);
assert.ok(received.some(item=>item.path.endsWith('/ai/runs/'+key+'?limit=100&after_seq=41')));
console.log('PASS frozen AI bytes, identity, version header, admission receipt and event cursor cross the actual Next proxy');
// 上游尾部仍挂起时读取第一块,随后释放并核对余下字节,验证端到端增量转发。
const streamed=await fetch(`http://127.0.0.1:${port}/api/foundry/${path.replace('/operations','')}`,{headers:{Accept:'application/vnd.foundry.canvas-model+ndjson;version=1'}});
assert.equal(streamed.headers.get('content-type'),'application/vnd.foundry.canvas-model+ndjson;version=1');
// 尾部 gate 尚未放开就等待首块,才能区分增量转发和整条缓冲;随后放开并排空剩余 body。
const reader=streamed.body.getReader(),first=await reader.read();assert.equal(new TextDecoder().decode(first.value),streamPrefix);streamRelease();let rest='';while(true){const next=await reader.read();if(next.done)break;rest+=new TextDecoder().decode(next.value);}assert.equal(rest,streamTail);reader.releaseLock();
console.log('PASS negotiated canonical NDJSON and first chunk cross the actual Next proxy before the gated tail');
// 多余路径段应在代理 allowlist 被拒绝,不能发到上游再依赖上游返回错误。
const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before);
console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB');
console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream');
}finally{
// 清理时先放开挂起流,再终止 Next 进程组;超时未退出则强制结束。
streamRelease();
if(next?.pid){const exited=new Promise(resolve=>{if(next.exitCode!==null)resolve();else next.once('exit',resolve)});try{process.kill(-next.pid,'SIGTERM')}catch{}await Promise.race([exited,pause(3000)]);if(next.exitCode===null){try{process.kill(-next.pid,'SIGKILL')}catch{}}}
// 断开上游残留连接并关闭监听,最后删除本次临时项目目录。
upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));await rm(directory,{recursive:true,force:true,maxRetries:5});
}