Exercise browser session persistence and PKCE through the production proxy

This commit is contained in:
2026-11-07 02:09:38 +08:00
parent af0cf1ba59
commit 4478750909
7 changed files with 255 additions and 2 deletions
+1
View File
@@ -6,4 +6,5 @@ out/
*.tsbuildinfo
playwright-report/
test-results/
.session-test-*/
.DS_Store
+3 -1
View File
@@ -19,6 +19,8 @@ pnpm typecheck
pnpm test
# 在另一个终端已运行 pnpm dev 后,使用系统 Chrome
pnpm test:browser
# 自动启动并清理隔离 dev 副本及模拟 Core,使用系统 Chrome
pnpm test:session
```
## 页面
@@ -46,7 +48,7 @@ pnpm test:browser
类型检查与13项测试已通过:除了Cookie/样本策略,测试直接调用生产认证代理,覆盖兑换、登录/邮件验证、续期、退出、错误、凭据隔离与请求限制。它们使用进程内模拟的Core响应,不代表真实OAuth成功,也不需要启动Next.js或编译。
12项桌面/手机浏览器测试覆盖搜索、分页、空结果、文章摘要与引用复制、三种主题持久化、登录/注册表单和404恢复;截图用于人工核对布局。真实OAuth尚未验收,生产构建按用户要求未执行。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
12项桌面/手机浏览器测试覆盖搜索、分页、空结果、文章摘要与引用复制、三种主题持久化、登录/注册表单和404恢复;截图用于人工核对布局。另有7项浏览器会话测试,使用隔离的实际Next dev副本和生产代理连接模拟Core,验证刷新/新标签恢复、HttpOnly Cookie、跨标签退出、故障重试、失效清理、Google/Slack模拟PKCE和邮箱验证的一次性兑换。测试不复制.env,不访问真实账户,结束后清理副本;真实OAuth尚未验收,生产构建按用户要求未执行。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
## 当前后端依赖
+4
View File
@@ -32,3 +32,7 @@ App Router 的 route.ts 直接调用 src/lib/auth-gateway.mjs。tests/auth-gatew
## 2026-11-07 配置更新
用户已确认新增 Thesisly 的 FOUNDRY_PROJECTS 回调。FOUNDRY_RESEARCH_DATABASE 为已撤回的 SQLite 路径方案,无需添加。论文数据正在独立任务迁移到 PostgreSQL;本任务等待迁移后的数据库/schema与只读接入方式,再完成 Core 论文 API。
## 浏览器会话回归
`pnpm test:session` 使用系统 Chrome 和独立端口17323/17324:复制应用源文件到可清理的临时目录,运行真实 Next dev、App Router 代理与浏览器,只有 Core 响应为合成服务。它不会读取项目 .env、占用17322预览、发送真实密码或连接真实 OAuth。7项测试验证HttpOnly/SameSite Cookie及客户端无token、刷新/新标签恢复、跨标签退出、暂时故障后重试、失效清理、退出失败保留会话,以及Google/Slack模拟PKCE和邮箱验证的一次性消费。它证明前端会话接线,不证明真实Core数据、邮件投递、提供方授权或生产配置。
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start --hostname localhost --port 17322",
"typecheck": "tsc --noEmit",
"test": "node --test tests/*.test.mjs",
"test:browser": "playwright test"
"test:browser": "playwright test",
"test:session": "playwright test --config=playwright.session.config.mjs"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from "@playwright/test";
// Separate dev copy + synthetic Core. Never runs a production build or uses
// the developer's .env, real Core, accounts, Chrome profile or preview port.
export default defineConfig({
testDir: "./tests/session",
outputDir: "test-results/session",
workers: 1,
retries: 0,
timeout: 60000,
expect: { timeout: 15000 },
reporter: "list",
use: {
baseURL: "http://localhost:17323",
channel: "chrome",
headless: true,
launchOptions: { args: ["--no-proxy-server"] },
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: {
command: "node tests/fixtures/session-app.mjs",
url: "http://localhost:17323/login",
reuseExistingServer: false,
timeout: 120000,
gracefulShutdown: { signal: "SIGTERM", timeout: 15000 },
},
});
+117
View File
@@ -0,0 +1,117 @@
// Synthetic Core only. The application and its production proxy run unchanged
// in a disposable Next dev copy; no real accounts, .env files or OAuth services.
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";
import { cp, mkdtemp, rm, symlink } from "node:fs/promises";
import { createRequire } from "node:module";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const root = fileURLToPath(new URL("../../", import.meta.url));
const origin = "http://localhost:17323";
const sessions = new Set(), grants = new Map();
let meFailure = false, logoutFailure = false, verified = false;
let counts = {};
function reset() {
sessions.clear(); grants.clear(); meFailure = false; logoutFailure = false; verified = false;
counts = { exchange: 0, verified: 0, logout: 0, google: 0, slack: 0 };
}
reset();
const token = () => randomBytes(32).toString("hex");
function reply(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(status === 204 ? undefined : JSON.stringify(data));
}
function session(res) {
const value = token(); sessions.add(value);
reply(res, 200, { access_token: value, expires_in: 2592000 });
}
async function body(req) {
const parts = [];
for await (const part of req) parts.push(part);
return JSON.parse(Buffer.concat(parts).toString() || "{}");
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost:17324");
if (url.pathname === "/__test/reset" && req.method === "POST") { reset(); return reply(res, 200, {}); }
if (url.pathname === "/__test/state") return reply(res, 200, { ...counts, sessions: sessions.size });
if (url.pathname === "/__test/control" && req.method === "POST") {
const data = await body(req);
if (data.expire) sessions.clear();
if (typeof data.meFailure === "boolean") meFailure = data.meFailure;
if (typeof data.logoutFailure === "boolean") logoutFailure = data.logoutFailure;
return reply(res, 200, {});
}
const match = /^\/projects\/thesisly\/auth\/([a-z-]+)$/.exec(url.pathname);
if (!match) return reply(res, 404, { error: "not_found" });
const action = match[1];
if (["google", "slack"].includes(action) && req.method === "GET") {
counts[action]++;
const challenge = url.searchParams.get("challenge");
if (!/^[-_a-zA-Z0-9]{43}$/.test(challenge || "")) return reply(res, 400, { error: "invalid_request" });
const code = token(); grants.set(code, challenge);
res.writeHead(302, { Location: `${origin}/auth/callback#code=${code}` }); return res.end();
}
if (action === "exchange" && req.method === "POST") {
counts.exchange++;
const code = req.headers["x-foundry-code"], verifier = req.headers["x-foundry-verifier"];
const expected = grants.get(code);
if (!expected || typeof verifier !== "string" || createHash("sha256").update(verifier).digest("base64url") !== expected) return reply(res, 400, { error: "invalid_verification" });
grants.delete(code); return session(res);
}
if (action === "register" && req.method === "POST") return reply(res, 202, { status: "verification_required" });
if (action === "verify-email" && req.method === "POST") {
counts.verified++;
const data = await body(req);
if (verified || data.token !== "fixture-email-verification") return reply(res, 400, { error: "invalid_verification" });
verified = true; return session(res);
}
if (action === "login" && req.method === "POST") {
const data = await body(req);
if (data.email !== "reader@example.test" || data.password !== "fixture-password-123") return reply(res, 401, { error: "invalid_credentials" });
return session(res);
}
const bearer = (req.headers.authorization || "").replace(/^Bearer /, "");
if (!sessions.has(bearer)) return reply(res, 401, { error: "invalid_session" });
if (action === "me" && req.method === "GET") {
if (meFailure) return reply(res, 503, { error: "temporarily_unavailable" });
res.setHeader("X-Foundry-Session-Expires-In", "2592000");
return reply(res, 200, { id: "fixture-account", email: "reader@example.test", project: "thesisly" });
}
if (action === "logout" && req.method === "POST") {
if (logoutFailure) return reply(res, 503, { error: "temporarily_unavailable" });
sessions.delete(bearer); counts.logout++; return reply(res, 204);
}
return reply(res, 405, { error: "method_not_allowed" });
} catch { reply(res, 500, { error: "fixture_error" }); }
});
const directory = await mkdtemp(join(root, ".session-test-"));
let child;
let closing = false;
async function close(code = 0) {
if (closing) return;
closing = true;
if (child && child.exitCode === null) {
child.kill("SIGTERM");
await new Promise(resolve => { child.once("exit", resolve); setTimeout(() => { child.kill("SIGKILL"); resolve(); }, 5000).unref(); });
}
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
await rm(directory, { recursive: true, force: true });
process.exit(code);
}
process.on("SIGTERM", () => void close());
process.on("SIGINT", () => void close());
try {
for (const name of ["src", "package.json", "tsconfig.json", "postcss.config.mjs", "next.config.ts", "next-env.d.ts"]) await cp(join(root, name), join(directory, name), { recursive: true });
await symlink(join(root, "node_modules"), join(directory, "node_modules"), "dir");
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(17324, "localhost", resolve); });
const require = createRequire(import.meta.url);
child = spawn(process.execPath, [require.resolve("next/dist/bin/next"), "dev", "--hostname", "localhost", "--port", "17323"], {
cwd: directory, stdio: "inherit", env: { ...process.env, FOUNDRY_PROJECT: "thesisly", FOUNDRY_API_ORIGIN: "http://localhost:17324", APP_ORIGIN: origin, PAPER_API_ORIGIN: "", NEXT_TELEMETRY_DISABLED: "1" },
});
child.once("exit", code => { if (!closing) void close(code || 1); });
} catch (error) { console.error(error.message); await close(1); }
+100
View File
@@ -0,0 +1,100 @@
import { test, expect } from "@playwright/test";
const core = "http://localhost:17324";
const cookieName = "research_reader_session";
const account = page => page.locator(".account-name");
const logout = page => page.getByRole("button", { name: "退出登录", exact: true });
const loginLink = page => page.getByRole("link", { name: "登录", exact: true });
async function control(request, data) { await request.post(core + "/__test/control", { data }); }
async function state(request) { return (await request.get(core + "/__test/state")).json(); }
async function login(page) {
await page.goto("/login");
await page.getByLabel("邮箱地址").fill("reader@example.test");
await page.getByLabel("密码", { exact: true }).fill("fixture-password-123");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(account(page)).toHaveText("reader@example.test");
}
test.beforeEach(async ({ request }) => { expect((await request.post(core + "/__test/reset")).ok()).toBe(true); });
test("HttpOnly session survives reload/new tabs and logout propagates", async ({ page, context, request }) => {
const responsePromise = page.waitForResponse(r => r.url().endsWith("/api/foundry/auth/login"));
await login(page);
const response = await responsePromise;
expect(await response.json()).not.toHaveProperty("access_token");
const cookie = (await context.cookies()).find(c => c.name === cookieName);
expect(cookie).toMatchObject({ httpOnly: true, sameSite: "Lax", path: "/" });
expect(cookie.expires).toBeGreaterThan(Date.now() / 1000 + 29 * 86400);
expect(await page.evaluate(() => document.cookie)).not.toContain(cookieName);
expect(await page.evaluate(() => [localStorage.length, sessionStorage.length])).toEqual([0, 0]);
await page.reload();
await expect(account(page)).toHaveText("reader@example.test");
const other = await context.newPage();
await other.goto("/");
await expect(account(other)).toHaveText("reader@example.test");
await logout(other).click();
await expect(loginLink(other)).toBeVisible();
await expect(loginLink(page)).toBeVisible();
expect((await context.cookies()).find(c => c.name === cookieName)).toBeUndefined();
expect((await state(request)).sessions).toBe(0);
});
test("temporary Core failure preserves cookie and retry restores the account", async ({ page, context, request }) => {
await login(page);
await control(request, { meFailure: true });
await page.reload();
await expect(page.getByRole("button", { name: "重试账户" })).toBeVisible();
expect((await context.cookies()).find(c => c.name === cookieName)).toBeDefined();
await control(request, { meFailure: false });
await page.getByRole("button", { name: "重试账户" }).click();
await expect(account(page)).toHaveText("reader@example.test");
});
test("revoked session is cleared and cannot restore on reload", async ({ page, context, request }) => {
await login(page);
await control(request, { expire: true });
await page.reload();
await expect(loginLink(page)).toBeVisible();
expect((await context.cookies()).find(c => c.name === cookieName)).toBeUndefined();
await page.reload();
await expect(loginLink(page)).toBeVisible();
expect((await state(request)).sessions).toBe(0);
});
test("failed logout retains the session until a successful retry", async ({ page, context, request }) => {
await login(page);
await control(request, { logoutFailure: true });
await logout(page).click();
await expect(page.getByText("退出失败,请重试", { exact: true })).toBeVisible();
await expect(account(page)).toHaveText("reader@example.test");
expect((await context.cookies()).find(c => c.name === cookieName)).toBeDefined();
await control(request, { logoutFailure: false });
await logout(page).click();
await expect(loginLink(page)).toBeVisible();
expect((await state(request)).logout).toBe(1);
});
for (const provider of ["Google", "Slack"]) test(`${provider} synthetic PKCE callback exchanges exactly once`, async ({ page, context, request }) => {
await page.goto("/login");
await page.getByRole("button", { name: provider, exact: true }).click();
await expect(account(page)).toHaveText("reader@example.test");
await expect(page).toHaveURL("http://localhost:17323/");
expect(await page.evaluate(() => sessionStorage.length)).toBe(0);
const stats = await state(request);
expect(stats.exchange).toBe(1);
expect(stats[provider.toLowerCase()]).toBe(1);
expect((await context.cookies()).find(c => c.name === cookieName)?.httpOnly).toBe(true);
});
test("registration requests verification and callback consumes its token once", async ({ page, request }) => {
await page.goto("/login");
await page.getByRole("button", { name: "创建账户", exact: true }).click();
await page.getByLabel("邮箱地址").fill("reader@example.test");
await page.getByLabel("密码", { exact: true }).fill("fixture-password-123");
await page.getByRole("button", { name: "创建账户", exact: true }).first().click();
await expect(page.getByRole("status")).toContainText("验证邮件已请求发送");
await expect(loginLink(page)).toBeVisible();
await page.goto("/auth/callback#verify_email=fixture-email-verification");
await expect(account(page)).toHaveText("reader@example.test");
await expect(page).toHaveURL("http://localhost:17323/");
expect((await state(request)).verified).toBe(1);
});