Exercise browser session persistence and PKCE through the production proxy
This commit is contained in:
@@ -6,4 +6,5 @@ out/
|
||||
*.tsbuildinfo
|
||||
playwright-report/
|
||||
test-results/
|
||||
.session-test-*/
|
||||
.DS_Store
|
||||
|
||||
@@ -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 分支实现。
|
||||
|
||||
## 当前后端依赖
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
Vendored
+117
@@ -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); }
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user