diff --git a/.gitignore b/.gitignore index b472e3c..fc0dc32 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ out/ *.tsbuildinfo playwright-report/ test-results/ +.session-test-*/ .DS_Store diff --git a/README.md b/README.md index af758c1..c308714 100644 --- a/README.md +++ b/README.md @@ -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 分支实现。 ## 当前后端依赖 diff --git a/docs/auth-integration.md b/docs/auth-integration.md index 69259a2..50b51e6 100644 --- a/docs/auth-integration.md +++ b/docs/auth-integration.md @@ -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数据、邮件投递、提供方授权或生产配置。 diff --git a/package.json b/package.json index f9f01c7..99e00dd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.session.config.mjs b/playwright.session.config.mjs new file mode 100644 index 0000000..8eedcd2 --- /dev/null +++ b/playwright.session.config.mjs @@ -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 }, + }, +}); diff --git a/tests/fixtures/session-app.mjs b/tests/fixtures/session-app.mjs new file mode 100644 index 0000000..5a11370 --- /dev/null +++ b/tests/fixtures/session-app.mjs @@ -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); } diff --git a/tests/session/auth.spec.mjs b/tests/session/auth.spec.mjs new file mode 100644 index 0000000..d104658 --- /dev/null +++ b/tests/session/auth.spec.mjs @@ -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); +});