Files
Endoleap/tests/api-smoke.mjs

420 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 已构建 Next 应用的本地 HTTP smoke:启动临时 Foundry 替身和 next start,遍历页面、代理、资源及认证响应。
* 需要已有可用构建,运行会占用 127.0.0.1:3005;脚本不执行 next build,也不连接真实 Foundry/模型服务。
* 断言以 HTTP、文件字节及内存状态为准,不验证浏览器交互、真实登录提供商或数据库事务。
*/
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { readdir, readFile } from "node:fs/promises";
// 应用使用固定端口;upstream 另选随机端口,由子进程环境变量指定为代理源站。
const host = "http://127.0.0.1:3005";
// 图谱、画布版本、会话剩余秒数与 profile 都是本次进程的内存夹具。
const graphs = new Map();
let releaseStream = () => {};
let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } };
let mockSessionSeconds = "2592000";
let mockProfile = {
id: "user", username: "test", display_name: "Test User", email: "test@example.com",
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
};
// 最小 Foundry HTTP 替身:有效测试会话才发续期提示,并按路由返回页面代理需要的响应。
const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds);
// 把 workspace/board slug 归一到同一记录,供四种 ID/slug 组合读取校验;不是通用路由解析器。
const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id");
if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2");
// 资源 GET 返回短 PNG 头字节或不支持 MIME;POST 汇总 multipart 原始字节并回显幂等键。
if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; }
if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]);
res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return;
}
// 先写 JSON 前缀,再延迟 15.2 秒完成 body,用于检查旧总超时不截断合法传输。
if (canonical.endsWith("/materials/slow-body-board/canvas")) {
res.setHeader("ETag", '\"210\"');
res.write('{"version":210,"document":');
await new Promise((resolve) => setTimeout(resolve, 15200));
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
}
// 由 releaseStream 控制尾部放行:代理必须在完整 JSON 可用之前转交头和首块。
if (canonical.endsWith("/materials/stream-board/canvas")) {
assert.equal(req.headers.authorization, "Bearer test-only-session");
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
res.write('{"version":1,"document":');
await new Promise((resolve) => { releaseStream = resolve; });
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
}
// V2 子命令只回显 URL、方法与关键头;这个分支不执行真正的连接、AI 或结构事务。
if (canonical.includes("/canvas/")) {
res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return;
}
// 旧完整快照 GET/PATCH 独立维护版本,If-Match 过期时返回 412;不与图谱元信息共用版本。
if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; }
if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
let raw = ""; for await (const chunk of req) raw += chunk;
if (req.headers["if-match"] !== '"' + canvasSnapshot.version + '"') { res.statusCode = 412; res.end(JSON.stringify({ error: "version_conflict" })); return; }
const { document } = JSON.parse(raw);
assert.equal(document.schema_version, 1); assert(Array.isArray(document.nodes));
canvasSnapshot = { version: canvasSnapshot.version + 1, document };
res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return;
}
// 其余分支提供登出、图谱创建/重命名、登录 token 和 profile;未知路由返回 404。
if (req.url.endsWith("/auth/logout")) {
res.statusCode = 204;
res.end();
} else if (req.url === "/projects/endoleap/workspaces/space/materials" && req.method === "POST") {
assert.equal(req.headers.authorization, "Bearer test-only-session");
let raw = "";
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } });
// 图谱 ID 由替身分配,同时保留 slug;创建响应只回身份字段,详细内容需后续 GET。
const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 };
graphs.set(graph.id, graph);
res.statusCode = 201;
res.end(JSON.stringify({ id: graph.id, workspace_id: graph.workspace_id, kind: graph.kind, version: graph.version }));
} else if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id") {
assert.equal(req.headers.authorization, "Bearer test-only-session");
if (req.method === "PATCH") {
assert.equal(req.headers["if-match"], '"1"');
let raw = "";
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
const current = graphs.get("foundry-generated-id");
graphs.set(current.id, { ...current, ...input, version: current.version + 1 });
res.statusCode = 204;
res.end();
} else res.end(JSON.stringify(graphs.get("foundry-generated-id")));
} else if (req.url.includes("/workspaces/space/materials")) {
res.end(JSON.stringify({ url: req.url, method: req.method }));
} else if (req.url.endsWith("/auth/login")) {
res.end(
JSON.stringify({
access_token: "test-only-session",
expires_in: 2592000,
token_type: "Bearer",
}),
);
} else if (req.url.endsWith("/profile")) {
res.statusCode =
req.headers.authorization === "Bearer test-only-session" ? 200 : 401;
if (res.statusCode === 200 && req.method === "PATCH") {
let raw = "";
for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw);
mockProfile = { ...mockProfile, username: input.username, display_name: input.display_name };
}
res.end(JSON.stringify(res.statusCode === 200 ? mockProfile : { error: "invalid_session" }));
} else {
res.statusCode = 404;
res.end("{}");
}
});
// 先监听随机端口再启动 Next 子进程,保证应用首次代理请求就能连到替身。
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
const port = upstream.address().port;
// 启动 next start 消费已有构建,捕获 stdout/stderr 供启动失败诊断,不运行开发服务器。
const app = spawn(
process.execPath,
[
"node_modules/next/dist/bin/next",
"start",
"--hostname",
"127.0.0.1",
"--port",
"3005",
],
{
cwd: fileURLToPath(new URL("../", import.meta.url)),
env: { ...process.env, FOUNDRY_API_ORIGIN: "http://127.0.0.1:" + port },
stdio: ["ignore", "pipe", "pipe"],
},
);
// 收集子进程启动输出;轮询中若提前退出,错误包含这些诊断文本。
let startup = "";
app.stdout.on("data", (x) => (startup += x));
app.stderr.on("data", (x) => (startup += x));
try {
// 最多轮询 100 次,失败后等待 100ms,再检查首页是否可用;不能只凭 spawn 返回就宣称服务已启动。
let ready = false;
for (let n = 0; n < 100; n++) {
if (app.exitCode !== null)
throw new Error("Frontend failed to start: " + startup);
try {
if ((await fetch(host)).ok) {
ready = true;
break;
}
} catch {}
await new Promise((r) => setTimeout(r, 100));
}
assert(ready, "Frontend did not start");
// 逐页检查状态和公共元信息;这些是服务端 HTML 断言,不代表页面客户端交互已验证。
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) {
const page = await fetch(host + path);
assert.equal(page.status, 200, path);
const html = await page.text();
assert.match(html, /<html lang="zh-CN"/);
assert.match(html, /啡跃 Endoleap · 让灵感有形/);
assert.match(html, /name="description"/);
assert.match(html, /name="viewport"/);
assert(html.includes('/brand/icons/favicon.ico'));
assert(html.includes('/brand/icons/apple-touch-icon.png'));
}
// 逐个下载本地品牌资产并与仓库原字节比较,检查静态服务没有替换或遗漏文件。
const brandRoot = new URL("../public/brand/", import.meta.url);
const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file));
assert.equal(assets.length, 88);
for (const asset of assets) {
const response = await fetch(host + "/brand/" + asset);
assert.equal(response.status, 200, asset);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(new URL(asset, brandRoot)), asset);
}
// 按 manifest 枚举图标分片,核对 immutable 缓存头和 JSON 内容,与目录数量的日志文案区分。
const iconManifest = JSON.parse(await readFile(new URL("../src/lib/canvas-icon-assets.json", import.meta.url), "utf8"));
for (const file of Object.values(iconManifest.assets)) {
const path = iconManifest.root + "/" + file;
const response = await fetch(host + path);
assert.equal(response.status, 200, path);
assert.match(response.headers.get("cache-control"), /max-age=31536000.*immutable/);
assert.deepEqual(await response.json(), JSON.parse(await readFile(new URL("../public" + path, import.meta.url), "utf8")));
}
assert.equal((await fetch(host + "/missing-page")).status, 404);
// 登录代理必须去掉响应体 token,改设 HttpOnly/SameSite cookie,并报告上游计时。
const res = await fetch(host + "/api/foundry/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: host },
body: "{}",
});
assert.equal(res.status, 200);
assert.match(res.headers.get("server-timing") || "", /upstream;dur=\d/);
const body = await res.json();
assert.equal(body.access_token, undefined);
const cookie = res.headers.get("set-cookie");
assert(cookie?.includes("HttpOnly"));
assert(cookie.includes("SameSite=Lax"));
assert(cookie.includes("Max-Age=2592000"));
const session = cookie.split(";")[0];
// Hold the upstream tail: snapshot headers and its first chunk must arrive
// before the proxy has the full JSON body (the old buffering path deadlocks).
let streamTimer;
try {
const streamed = await Promise.race([fetch(host + "/api/foundry/workspaces/space/materials/stream-board/canvas", { headers: { Cookie: session } }), new Promise((_, reject) => { streamTimer = setTimeout(() => reject(new Error("Snapshot proxy buffered the body")), 2500); })]);
assert.equal(streamed.headers.get("etag"), '\"1\"'); assert.equal(streamed.headers.get("x-canvas-schema-version"), "2");
assert.match(streamed.headers.get("server-timing"), /upstream;dur=.*proxy_headers;dur=/);
assert.ok(streamed.headers.get("set-cookie")?.includes("HttpOnly"));
const reader = streamed.body.getReader(); const first = await reader.read(); assert.ok(first.value.length > 0);
releaseStream(); let raw = new TextDecoder().decode(first.value);
while (true) { const next = await reader.read(); if (next.done) break; raw += new TextDecoder().decode(next.value); }
assert.equal(JSON.parse(raw).document.schema_version, 2);
} finally { clearTimeout(streamTimer); releaseStream(); }
// 子命令循环只检查头透传;随后检查资源 variant、目录分页与 AI 查询参数白名单。
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
const idem = "11111111-1111-4111-8111-111111111111";
for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) {
const response = await fetch(v2Base + "/" + path, { method: "POST", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "Idempotency-Key": idem, "If-Match": '\"7\"' }, body: "{}" });
assert.equal(response.status, 200, path); const echo = await response.json(); assert.equal(echo.key, idem); assert.equal(echo.version, '\"7\"'); assert.equal(echo.accept, "application/vnd.foundry.canvas+json;version=2");
}
const preview = await fetch(host + "/api/foundry/workspaces/space/assets/image?variant=preview", { headers: { Cookie: session } });assert.equal(preview.status,200);assert.equal(preview.headers.get("x-asset-variant"),"preview");await preview.arrayBuffer();
const directoryResponse = await (await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&sort=modified&include_subgraphs=true&limit=100&offset=100", { headers: { Cookie: session } })).json();
const directoryQuery = new URL(directoryResponse.url, "http://mock").searchParams;
assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100");
const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json();
const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null);
// 验证慢 body 完整读取和 trace 标识,再检查资源认证、MIME 拒绝、nosniff 与 multipart 转发。
const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } });
assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline");
const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
assert.equal(asset.status, 200); assert.equal(asset.headers.get("content-type"), "image/png"); assert.equal(asset.headers.get("x-content-type-options"), "nosniff"); assert.ok(asset.headers.get("set-cookie")?.includes("HttpOnly")); assert.equal((await asset.arrayBuffer()).byteLength, 8);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/image-1")).status, 401);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/bad", { headers: { Cookie: session } })).status, 502);
const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png");
const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json();
assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart);
// 有效会话首页重定向且不缓存私有结果;dashboard 初始 HTML 不应包含画布或 landing。
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
assert.equal(signedInHome.status, 307);
assert.equal(signedInHome.headers.get("location"), "/dashboard");
assert.match(signedInHome.headers.get("cache-control") || "", /private|no-store/);
assert(!(await signedInHome.text()).includes("test-only-session"));
const dashboardHome = await fetch(host + "/dashboard", { headers: { Cookie: session } });
assert.equal(dashboardHome.status, 200);
const dashboardHtml = await dashboardHome.text();
assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/);
assert(!dashboardHtml.includes('data-testid="rf__wrapper"'));
assert(!dashboardHtml.includes('class="landing"'));
// 无会话或过期会话首页保持 landing,避免错误重定向到个人工作区。
for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) {
const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" });
assert.equal(guestHome.status, 200);
assert.equal(guestHome.headers.get("location"), null);
const html = await guestHome.text();
assert(html.includes('class="landing"'));
assert(!html.includes('aria-label="工作空间侧边栏"'));
}
assert.equal(
(
await fetch(host + "/api/foundry/profile", {
headers: { Cookie: session },
})
).status,
200,
);
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
// 续期只接受有效剩余秒数;非法上游值和无效会话不应产生新的 Set-Cookie。
mockSessionSeconds = "3600";
const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
assert.equal(renewed.status, 200); assert.match(renewed.headers.get("set-cookie") || "", /Max-Age=3600/);
assert.match(renewed.headers.get("set-cookie") || "", /HttpOnly/);
mockSessionSeconds = "garbage";
const malformedRenewal = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
assert.equal(malformedRenewal.headers.get("set-cookie"), null);
mockSessionSeconds = "2592000";
const expiredSession = await fetch(host + "/api/foundry/profile", { headers: { Cookie: "foundry_session=expired" } });
assert.equal(expiredSession.status, 401); assert.equal(expiredSession.headers.get("set-cookie"), null);
// 读取头像再修改公开资料,确认未涉及的头像字段保持。
const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json();
assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c");
const updated = await fetch(host + "/api/foundry/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
body: JSON.stringify({ username: "test", display_name: "Updated name" }),
});
assert.equal(updated.status, 200);
const updatedProfile = await updated.json();
assert.equal(updatedProfile.display_name, "Updated name");
assert.equal(updatedProfile.avatar_url, profile.avatar_url);
// 经真实代理创建、读取和重命名内存图谱,检查服务分配身份、内容保留和元信息版本递增。
const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
body: JSON.stringify({ kind: "board", title: "未命名图谱", content: { schema_version: 1 } }),
});
assert.equal(create.status, 201);
const graph = await create.json();
assert.deepEqual(graph, { id: "foundry-generated-id", workspace_id: "space", kind: "board", version: 1 });
const opened = await fetch(host + `/api/foundry/workspaces/${graph.workspace_id}/materials/${graph.id}`, { headers: { Cookie: session } });
assert.equal(opened.status, 200);
const stored = await opened.json();
assert.equal(stored.title, "未命名图谱");
assert.equal(stored.workspace_id, "space");
assert.equal(stored.content.schema_version, 1);
const rename = await fetch(host + `/api/foundry/workspaces/${graph.workspace_id}/materials/${graph.id}`, {
method: "PATCH", headers: { "Content-Type": "application/json", Cookie: session, Origin: host, "If-Match": '"1"' },
body: JSON.stringify({ title: "改名后的图谱", description: "", content: stored.content }),
});
assert.equal(rename.status, 204);
const renamed = await (await fetch(host + `/api/foundry/workspaces/${graph.workspace_id}/materials/${graph.id}`, { headers: { Cookie: session } })).json();
assert.equal(renamed.title, "改名后的图谱");
assert.equal(renamed.version, 2);
assert.deepEqual(renamed.content, stored.content);
// workspace/board 各自使用 ID 或 slug 的四种组合都应定位同一图谱和独立画布版本。
for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) {
const path = `/api/foundry/workspaces/${workspace}/materials/${board}`;
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
assert.equal(material.id, "foundry-generated-id"); assert.equal(material.slug, "my-board");
const snapshot = await fetch(host + path + "/canvas", { headers: { Cookie: session } });
assert.equal(snapshot.status, 200); assert.equal(snapshot.headers.get("etag"), '"1"');
assert.equal((await snapshot.json()).version, 1);
}
const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas";
// 生成超过 1 MiB 的完整快照经代理保存/恢复,再用旧版本重发检查 412;不是增量或大文件性能测试。
const largeDocument = { schema_version: 1, nodes: Array.from({ length: 15 }, (_, index) => ({ id: "text-" + index, type: "material", position: { x: index * 300, y: 100 }, width: 260, height: 200, data: { kind: "text", title: "保存测试", text: "x".repeat(90000), fontSize: 24, rotation: 30, textStyle: { bold: true, color: "#123456" } } })), edges: [], viewport: { x: 20, y: 40, zoom: 0.75 } };
const savedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"');
assert.equal((await savedCanvas.json()).version, 2);
const restoredCanvas = await (await fetch(host + canvasPath, { headers: { Cookie: session } })).json();
assert.deepEqual(restoredCanvas.document, largeDocument);
const conflict = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict");
// 旧 frameId 成员关系及三种名称显示模式往返,逐次使用当前画布版本。
const frameDocument = structuredClone(largeDocument);
frameDocument.nodes.unshift({ id: "frame-1", type: "frame", position: { x: 0, y: 0 }, width: 640, height: 400, data: { kind: "frame", title: "参考分区", text: "", rotation: 0, labelMode: "fixed" } });
frameDocument.nodes[1].data.frameId = "frame-1";
for (const [index, mode] of ["hidden", "fixed", "scaled"].entries()) {
frameDocument.nodes[0].data.labelMode = mode;
const response = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"' + (2 + index) + '"' }, body: JSON.stringify({ document: frameDocument }) });
assert.equal(response.status, 200);
const restored = await (await fetch(host + canvasPath, { headers: { Cookie: session } })).json();
assert.equal(restored.document.nodes[0].data.labelMode, mode);
assert.equal(restored.document.nodes[1].data.frameId, "frame-1");
}
assert.equal((await fetch(host + canvasPath)).status, 401);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404);
// 超过画布 body 上限的请求必须返回 413,不能转成成功保存。
const oversizedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, "Content-Type": "application/json" }, body: JSON.stringify({ document: "x".repeat(5 * 1024 * 1024) }) });
assert.equal(oversizedCanvas.status, 413);
// 检查跨源拒绝、路径白名单、OAuth challenge、Secure cookie、非法 JSON、大小上限及方法过滤。
for (const origin of ["https://example.com", "null"])
assert.equal(
(
await fetch(host + "/api/foundry/auth/login", {
method: "POST",
headers: { Origin: origin, "Content-Type": "application/json" },
body: "{}",
})
).status,
403,
);
assert.equal((await fetch(host + "/api/foundry/unlisted")).status, 404);
assert.equal(
(await fetch(host + "/api/oauth/slack?challenge=bad")).status,
400,
);
assert.equal(res.headers.get("cache-control"), "no-store");
const secure = await fetch(host + "/api/foundry/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Forwarded-Proto": "https" },
body: "{}",
});
assert.match(secure.headers.get("set-cookie"), /; Secure/);
for (const [body, status] of [["{", 400], [JSON.stringify({ text: "x".repeat(1024 * 1024) }), 413]]) {
const invalid = await fetch(host + "/api/foundry/auth/login", {
method: "POST", headers: { "Content-Type": "application/json" }, body,
});
assert.equal(invalid.status, status);
}
assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405);
// 查询保留 kind/limit 而删除未许可 secret;此处使用替身回显观察最终上游路径。
const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
});
assert.deepEqual(await materials.json(), {
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
});
// OAuth 只检查重定向地址与 challenge,不实际打开提供商或完成授权。
for (const provider of ["google", "slack"]) {
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
assert.equal(redirect.status, 302);
assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`);
}
// 登出保持 204 空体并清除 cookie;下方 PASS 长文是摘要,实际覆盖以逐项断言为准。
const logout = await fetch(host + "/api/foundry/auth/logout", {
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
});
assert.equal(logout.status, 204);
assert.equal(await logout.text(), "");
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
console.log(
"PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
);
// 结束或断言失败均终止 Next 子进程并关闭替身监听;此处没有额外的强制退出超时。
} finally {
const exited = once(app, "exit");
app.kill("SIGTERM");
await exited;
await new Promise((r) => upstream.close(r));
}