235 lines
10 KiB
JavaScript
235 lines
10 KiB
JavaScript
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";
|
|
const host = "http://127.0.0.1:3005";
|
|
const graphs = new Map();
|
|
let mockProfile = {
|
|
id: "user", username: "test", display_name: "Test User", email: "test@example.com",
|
|
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
|
|
};
|
|
const upstream = createServer(async (req, res) => {
|
|
res.setHeader("Content-Type", "application/json");
|
|
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 } });
|
|
const graph = { ...input, 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 (req.url === "/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: 28800,
|
|
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("{}");
|
|
}
|
|
});
|
|
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
|
const port = upstream.address().port;
|
|
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 {
|
|
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");
|
|
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);
|
|
}
|
|
assert.equal((await fetch(host + "/missing-page")).status, 404);
|
|
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=28800"));
|
|
const session = cookie.split(";")[0];
|
|
assert.equal(
|
|
(
|
|
await fetch(host + "/api/foundry/profile", {
|
|
headers: { Cookie: session },
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
|
|
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);
|
|
|
|
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);
|
|
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",
|
|
});
|
|
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)}`);
|
|
}
|
|
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: 88 original brand assets, 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",
|
|
);
|
|
} finally {
|
|
const exited = once(app, "exit");
|
|
app.kill("SIGTERM");
|
|
await exited;
|
|
await new Promise((r) => upstream.close(r));
|
|
}
|