206 lines
8.6 KiB
JavaScript
206 lines
8.6 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";
|
|
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");
|
|
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.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);
|
|
|
|
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: 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));
|
|
}
|