diff --git a/src/app/api/__tests__/auth-session.test.ts b/src/app/api/__tests__/auth-session.test.ts new file mode 100644 index 0000000..e1e81fc --- /dev/null +++ b/src/app/api/__tests__/auth-session.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + DELETE as deleteSession, + GET as getSession, + POST as createSession, +} from "../auth/session/route"; + +describe("auth session API", () => { + const originalApiKey = process.env.API_ACCESS_KEY; + const originalAuthDisabled = process.env.API_AUTH_DISABLED; + + beforeEach(() => { + process.env.API_ACCESS_KEY = "test-key"; + process.env.API_AUTH_DISABLED = "false"; + }); + + afterEach(() => { + process.env.API_ACCESS_KEY = originalApiKey; + process.env.API_AUTH_DISABLED = originalAuthDisabled; + }); + + it("creates an HttpOnly browser session after validating the access key", async () => { + const response = await createSession(request({ api_access_key: "test-key" })); + const body = (await response.json()) as { authenticated: boolean }; + const setCookie = response.headers.get("set-cookie") ?? ""; + + expect(response.status).toBe(200); + expect(body.authenticated).toBe(true); + expect(setCookie).toContain("geo_api_session="); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toMatch(/SameSite=Lax/i); + expect(setCookie).toContain("Path=/"); + expect(setCookie).not.toContain("test-key"); + }); + + it("rejects invalid access keys without setting a session cookie", async () => { + const response = await createSession(request({ api_access_key: "wrong" })); + + expect(response.status).toBe(401); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it("reports the current browser session state", async () => { + const login = await createSession(request({ api_access_key: "test-key" })); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + + const anonymous = await getSession(new Request("http://localhost/api/auth/session")); + const authenticated = await getSession( + new Request("http://localhost/api/auth/session", { + headers: { cookie }, + }), + ); + + await expect(anonymous.json()).resolves.toEqual({ authenticated: false }); + await expect(authenticated.json()).resolves.toEqual({ authenticated: true }); + }); + + it("clears the browser session cookie", async () => { + const response = await deleteSession( + new Request("http://localhost/api/auth/session", { method: "DELETE" }), + ); + const setCookie = response.headers.get("set-cookie") ?? ""; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ authenticated: false }); + expect(setCookie).toContain("geo_api_session="); + expect(setCookie).toContain("Max-Age=0"); + expect(setCookie).toContain("HttpOnly"); + }); +}); + +function request(body: unknown) { + return new Request("http://localhost/api/auth/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..47ef3e6 --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; + +import { + apiSessionCookieName, + createApiSessionToken, + getConfiguredApiAccessKey, + hasValidApiSession, +} from "../../../../lib/api/auth"; + +const sessionMaxAgeSeconds = 60 * 60 * 24 * 30; + +export async function GET(request: Request) { + if (process.env.API_AUTH_DISABLED === "true") { + return NextResponse.json({ authenticated: true }); + } + + const apiAccessKey = getConfiguredApiAccessKey(); + return NextResponse.json({ + authenticated: apiAccessKey + ? hasValidApiSession(request, apiAccessKey) + : false, + }); +} + +export async function POST(request: Request) { + if (process.env.API_AUTH_DISABLED === "true") { + return NextResponse.json({ authenticated: true }); + } + + const apiAccessKey = getConfiguredApiAccessKey(); + if (!apiAccessKey) { + return NextResponse.json( + { error: "API access key is not configured" }, + { status: 401 }, + ); + } + + const body = (await request.json().catch(() => ({}))) as { + api_access_key?: unknown; + apiAccessKey?: unknown; + }; + const provided = + typeof body.api_access_key === "string" + ? body.api_access_key + : body.apiAccessKey; + + if (provided !== apiAccessKey) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const response = NextResponse.json({ authenticated: true }); + response.cookies.set({ + name: apiSessionCookieName, + value: createApiSessionToken(apiAccessKey), + httpOnly: true, + sameSite: "lax", + secure: new URL(request.url).protocol === "https:", + path: "/", + maxAge: sessionMaxAgeSeconds, + }); + return response; +} + +export async function DELETE(request: Request) { + const response = NextResponse.json({ authenticated: false }); + response.cookies.set({ + name: apiSessionCookieName, + value: "", + httpOnly: true, + sameSite: "lax", + secure: new URL(request.url).protocol === "https:", + path: "/", + maxAge: 0, + }); + return response; +} diff --git a/src/app/globals.css b/src/app/globals.css index 1fcd297..4bb4346 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -128,6 +128,22 @@ h3 { min-width: min(16rem, 100%); } +.api-session-control { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + max-width: 28rem; +} + +.api-session-control small { + color: #586174; + flex-basis: 100%; + font-size: 0.78rem; + text-align: right; +} + .text-link { color: #2f6fdd; font-weight: 800; diff --git a/src/components/cases/__tests__/api-client.test.ts b/src/components/cases/__tests__/api-client.test.ts new file mode 100644 index 0000000..6774052 --- /dev/null +++ b/src/components/cases/__tests__/api-client.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + apiFetch, + getApiSession, + loginApiSession, + logoutApiSession, +} from "../api-client"; + +describe("case API session client", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("checks browser session state with same-origin credentials", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ authenticated: true })), + ); + + await expect(getApiSession()).resolves.toEqual({ authenticated: true }); + expect(fetch).toHaveBeenCalledWith("/api/auth/session", { + credentials: "same-origin", + }); + }); + + it("creates a browser session with the provided access key", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ authenticated: true })), + ); + + await expect(loginApiSession("local-dev-key")).resolves.toEqual({ + authenticated: true, + }); + expect(fetch).toHaveBeenCalledWith("/api/auth/session", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ api_access_key: "local-dev-key" }), + }); + }); + + it("surfaces login errors from the session endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ error: "Unauthorized" }, 401)), + ); + + await expect(loginApiSession("wrong")).resolves.toEqual({ + authenticated: false, + error: "Unauthorized", + }); + }); + + it("clears the browser session", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ authenticated: false })), + ); + + await expect(logoutApiSession()).resolves.toEqual({ authenticated: false }); + expect(fetch).toHaveBeenCalledWith("/api/auth/session", { + method: "DELETE", + credentials: "same-origin", + }); + }); + + it("adds same-origin credentials to protected API requests", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ ok: true }))); + + await apiFetch("/api/cases", { headers: { accept: "application/json" } }); + + expect(fetch).toHaveBeenCalledWith("/api/cases", { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + }); +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/src/components/cases/api-client.ts b/src/components/cases/api-client.ts index 311c8b9..dfc5e25 100644 --- a/src/components/cases/api-client.ts +++ b/src/components/cases/api-client.ts @@ -1,4 +1,7 @@ -export const apiAccessStorageKey = "geo-api-access-key"; +export interface ApiSessionResult { + authenticated: boolean; + error?: string; +} export function apiHeaders(apiAccessKey: string) { const headers: Record = { "content-type": "application/json" }; @@ -8,17 +11,43 @@ export function apiHeaders(apiAccessKey: string) { return headers; } -export function readStoredApiAccessKey() { - if (typeof window === "undefined") return ""; - return window.sessionStorage.getItem(apiAccessStorageKey) ?? ""; +export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { + return fetch(input, { + ...init, + credentials: init.credentials ?? "same-origin", + }); } -export function storeApiAccessKey(value: string) { - if (typeof window === "undefined") return; - if (value) { - window.sessionStorage.setItem(apiAccessStorageKey, value); - } else { - window.sessionStorage.removeItem(apiAccessStorageKey); - } +export async function getApiSession(): Promise { + const response = await apiFetch("/api/auth/session"); + return readSessionResponse(response); } +export async function loginApiSession( + apiAccessKey: string, +): Promise { + const response = await apiFetch("/api/auth/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ api_access_key: apiAccessKey }), + }); + return readSessionResponse(response); +} + +export async function logoutApiSession(): Promise { + const response = await apiFetch("/api/auth/session", { + method: "DELETE", + }); + return readSessionResponse(response); +} + +async function readSessionResponse(response: Response): Promise { + const body = (await response.json().catch(() => ({}))) as { + authenticated?: unknown; + error?: unknown; + }; + return { + authenticated: response.ok && body.authenticated === true, + error: typeof body.error === "string" ? body.error : undefined, + }; +} diff --git a/src/components/cases/case-auth-control.tsx b/src/components/cases/case-auth-control.tsx new file mode 100644 index 0000000..40385db --- /dev/null +++ b/src/components/cases/case-auth-control.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useState, type FormEvent } from "react"; + +import { + getApiSession, + loginApiSession, + logoutApiSession, +} from "./api-client"; + +interface CaseAuthControlProps { + authenticated: boolean; + onAuthenticatedChange: (authenticated: boolean) => void; +} + +export function CaseAuthControl({ + authenticated, + onAuthenticatedChange, +}: CaseAuthControlProps) { + const [apiAccessKey, setApiAccessKey] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const [message, setMessage] = useState(""); + + useEffect(() => { + let isCurrent = true; + getApiSession() + .then((session) => { + if (!isCurrent) return; + onAuthenticatedChange(session.authenticated); + setMessage(session.authenticated ? "已登录" : ""); + }) + .catch(() => { + if (!isCurrent) return; + onAuthenticatedChange(false); + setMessage("登录状态检查失败"); + }) + .finally(() => { + if (isCurrent) setIsChecking(false); + }); + return () => { + isCurrent = false; + }; + }, [onAuthenticatedChange]); + + async function submit(event: FormEvent) { + event.preventDefault(); + const trimmed = apiAccessKey.trim(); + if (!trimmed) { + setMessage("请输入访问密钥。"); + return; + } + + setIsSubmitting(true); + setMessage(""); + try { + const session = await loginApiSession(trimmed); + onAuthenticatedChange(session.authenticated); + if (session.authenticated) { + setApiAccessKey(""); + setMessage("已登录"); + } else { + setMessage(session.error ?? "访问密钥无效或已失效"); + } + } catch (error) { + onAuthenticatedChange(false); + setMessage(error instanceof Error ? error.message : "登录失败"); + } finally { + setIsSubmitting(false); + } + } + + async function logout() { + setIsSubmitting(true); + setMessage(""); + try { + await logoutApiSession(); + onAuthenticatedChange(false); + setMessage("已退出登录。"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "退出登录失败"); + } finally { + setIsSubmitting(false); + } + } + + if (authenticated) { + return ( +
+ 已登录 + + {message && message !== "已登录" ? {message} : null} +
+ ); + } + + return ( +
+ + + {message ? {message} : null} +
+ ); +} diff --git a/src/components/cases/case-detail.tsx b/src/components/cases/case-detail.tsx index 011f4b4..f6d2cf1 100644 --- a/src/components/cases/case-detail.tsx +++ b/src/components/cases/case-detail.tsx @@ -1,18 +1,15 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { OptimizationCase, OptimizationCaseDetail, } from "../../lib/cases/types"; -import { - apiHeaders, - readStoredApiAccessKey, - storeApiAccessKey, -} from "./api-client"; +import { apiFetch } from "./api-client"; import { ArticleCaseDetail } from "./article-case-detail"; +import { CaseAuthControl } from "./case-auth-control"; import { CasePublicationPanel } from "./case-publication-panel"; import { formatDate } from "./case-format"; import { CaseResultVersionList } from "./case-result-version-list"; @@ -41,12 +38,15 @@ const statusLabels: Record = { export function CaseDetail({ caseId }: CaseDetailProps) { const [detail, setDetail] = useState(null); const [selectedVersionId, setSelectedVersionId] = useState(null); - const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey); + const [authenticated, setAuthenticated] = useState(false); + const [sessionChecked, setSessionChecked] = useState(false); const [message, setMessage] = useState(""); useEffect(() => { + if (!sessionChecked || !authenticated) return; + let isCurrent = true; - fetch(`/api/cases/${caseId}`, { headers: apiHeaders(apiAccessKey) }) + apiFetch(`/api/cases/${caseId}`) .then((response) => response.json() as Promise) .then((body: CaseDetailResponse) => { if (!isCurrent) return; @@ -76,7 +76,7 @@ export function CaseDetail({ caseId }: CaseDetailProps) { return () => { isCurrent = false; }; - }, [apiAccessKey, caseId]); + }, [authenticated, caseId, sessionChecked]); const selectedVersion = useMemo(() => { if (!detail) return null; @@ -87,31 +87,39 @@ export function CaseDetail({ caseId }: CaseDetailProps) { ); }, [detail, selectedVersionId]); - function updateApiAccessKey(value: string) { - setApiAccessKey(value); - storeApiAccessKey(value); - } + const updateAuthenticated = useCallback((nextAuthenticated: boolean) => { + setAuthenticated(nextAuthenticated); + setSessionChecked(true); + setMessage(""); + if (!nextAuthenticated) { + setDetail(null); + setSelectedVersionId(null); + } + }, []); + + const visibleMessage = + message || + (sessionChecked && !authenticated ? "请先登录后查看案例详情。" : ""); return (

{detail?.case.title ?? "案例详情"}

- {message ?

{message}

: detail ?

{detail.case.summary}

: null} + {visibleMessage ? ( +

{visibleMessage}

+ ) : detail ? ( +

{detail.case.summary}

+ ) : null}
返回案例库 - +
@@ -168,7 +176,6 @@ export function CaseDetail({ caseId }: CaseDetailProps) { /> )} { const params = new URLSearchParams(); @@ -44,10 +42,10 @@ export function CaseList() { }, [caseType, status, query]); useEffect(() => { + if (!sessionChecked || !authenticated) return; + let isCurrent = true; - fetch(`/api/cases${search ? `?${search}` : ""}`, { - headers: apiHeaders(apiAccessKey), - }) + apiFetch(`/api/cases${search ? `?${search}` : ""}`) .then((response) => response.json() as Promise) .then((body: CaseListResponse) => { if (!isCurrent) return; @@ -61,33 +59,36 @@ export function CaseList() { return () => { isCurrent = false; }; - }, [apiAccessKey, search]); + }, [authenticated, search, sessionChecked]); - function updateApiAccessKey(value: string) { - setApiAccessKey(value); - storeApiAccessKey(value); - } + const updateAuthenticated = useCallback((nextAuthenticated: boolean) => { + setAuthenticated(nextAuthenticated); + setSessionChecked(true); + setMessage(""); + if (!nextAuthenticated) { + setCases([]); + } + }, []); + + const visibleMessage = + message || + (sessionChecked && !authenticated ? "请先登录后查看案例库。" : ""); return (

案例库

- {message ?

{message}

: null} + {visibleMessage ?

{visibleMessage}

: null}
返回优化台 - +
@@ -152,7 +153,7 @@ export function CaseList() { {formatDate(item.updated_at)} ))} - {cases.length === 0 ? ( + {authenticated && cases.length === 0 ? (

还没有可显示的案例。

) : null}
diff --git a/src/components/cases/case-publication-panel.tsx b/src/components/cases/case-publication-panel.tsx index e219dec..5bedb14 100644 --- a/src/components/cases/case-publication-panel.tsx +++ b/src/components/cases/case-publication-panel.tsx @@ -3,11 +3,10 @@ import { useEffect, useState } from "react"; import type { PublicationRecord } from "../../lib/calibration/types"; -import { apiHeaders } from "./api-client"; +import { apiFetch } from "./api-client"; import { formatDate } from "./case-format"; interface CasePublicationPanelProps { - apiAccessKey: string; caseId: string; versionId: string | null; publishTarget: string; @@ -30,7 +29,6 @@ interface PerformanceResponse { } export function CasePublicationPanel({ - apiAccessKey, caseId, versionId, publishTarget, @@ -54,9 +52,7 @@ export function CasePublicationPanel({ return; } let isCurrent = true; - fetch(`/api/cases/${caseId}/versions/${versionId}/publications`, { - headers: apiHeaders(apiAccessKey), - }) + apiFetch(`/api/cases/${caseId}/versions/${versionId}/publications`) .then((response) => response.json() as Promise) .then((body: PublicationListResponse) => { if (!isCurrent) return; @@ -70,16 +66,16 @@ export function CasePublicationPanel({ return () => { isCurrent = false; }; - }, [apiAccessKey, caseId, versionId]); + }, [caseId, versionId]); async function registerPublication() { if (!versionId || !url || !publishedAt) return; setMessage(""); - const response = await fetch( + const response = await apiFetch( `/api/cases/${caseId}/versions/${versionId}/publications`, { method: "POST", - headers: apiHeaders(apiAccessKey), + headers: { "content-type": "application/json" }, body: JSON.stringify({ publish_target: publishTarget || "未指定", url, @@ -101,9 +97,9 @@ export function CasePublicationPanel({ async function recordPerformance() { if (!publicationId) return; setMessage(""); - const response = await fetch(`/api/publications/${publicationId}/performance`, { + const response = await apiFetch(`/api/publications/${publicationId}/performance`, { method: "POST", - headers: apiHeaders(apiAccessKey), + headers: { "content-type": "application/json" }, body: JSON.stringify({ window_label: "T+7d", views, diff --git a/src/lib/api/__tests__/auth.test.ts b/src/lib/api/__tests__/auth.test.ts index 41f98c2..3142659 100644 --- a/src/lib/api/__tests__/auth.test.ts +++ b/src/lib/api/__tests__/auth.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { requireApiAccess } from "../auth"; +import { + apiSessionCookieName, + createApiSessionToken, + requireApiAccess, +} from "../auth"; describe("requireApiAccess", () => { test("allows local test requests when auth is explicitly disabled", () => { @@ -53,4 +57,32 @@ describe("requireApiAccess", () => { expect(result.ok).toBe(true); }); + + test("allows requests with a valid HttpOnly session cookie", () => { + const request = new Request("http://localhost/api/jobs", { + headers: { + cookie: `${apiSessionCookieName}=${createApiSessionToken("secret")}`, + }, + }); + + const result = requireApiAccess(request, { + apiAccessKey: "secret", + authDisabled: false, + }); + + expect(result.ok).toBe(true); + }); + + test("rejects requests with an invalid session cookie", () => { + const request = new Request("http://localhost/api/jobs", { + headers: { cookie: `${apiSessionCookieName}=wrong` }, + }); + + const result = requireApiAccess(request, { + apiAccessKey: "secret", + authDisabled: false, + }); + + expect(result.ok).toBe(false); + }); }); diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 68d865e..6326a1e 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,7 +1,13 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + import { NextResponse } from "next/server"; import { getAppCloudflareEnv } from "../runtime/cloudflare"; +export const apiSessionCookieName = "geo_api_session"; + +const sessionTokenMessage = "geo-agent-api-session:v1"; + interface ApiAccessOptions { apiAccessKey?: string; authDisabled?: boolean; @@ -33,7 +39,10 @@ export function requireApiAccess( } const provided = request.headers.get("x-api-key"); - if (provided !== options.apiAccessKey) { + if ( + provided !== options.apiAccessKey && + !hasValidApiSession(request, options.apiAccessKey) + ) { return { ok: false, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), @@ -43,6 +52,42 @@ export function requireApiAccess( return { ok: true }; } -function getConfiguredApiAccessKey() { +export function hasValidApiSession(request: Request, apiAccessKey: string) { + const cookieValue = parseCookieHeader(request.headers.get("cookie"))[ + apiSessionCookieName + ]; + if (!cookieValue) return false; + return safeEqual(cookieValue, createApiSessionToken(apiAccessKey)); +} + +export function createApiSessionToken(apiAccessKey: string) { + return createHmac("sha256", apiAccessKey) + .update(sessionTokenMessage) + .digest("hex"); +} + +export function getConfiguredApiAccessKey() { return getAppCloudflareEnv()?.API_ACCESS_KEY ?? process.env.API_ACCESS_KEY; } + +function parseCookieHeader(value: string | null) { + const cookies: Record = {}; + if (!value) return cookies; + + for (const part of value.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + const name = part.slice(0, separator).trim(); + const cookieValue = part.slice(separator + 1).trim(); + if (name) cookies[name] = decodeURIComponent(cookieValue); + } + + return cookies; +} + +function safeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} diff --git a/tests/e2e/cases.spec.ts b/tests/e2e/cases.spec.ts index 9c86b29..63d207e 100644 --- a/tests/e2e/cases.spec.ts +++ b/tests/e2e/cases.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; test("案例库列表和人味文案详情可查看", async ({ page }) => { + await mockAuthenticatedSession(page, true); await page.route("**/api/cases", async (route) => { await route.fulfill({ status: 200, @@ -134,3 +135,84 @@ test("案例库列表和人味文案详情可查看", async ({ page }) => { await expect(page.getByText("AI 味检查")).toBeVisible(); await expect(page.getByText("发布表现")).toBeVisible(); }); + +test("案例库可以通过访问密钥建立浏览器登录态", async ({ page }) => { + let authenticated = false; + await page.route("**/api/auth/session", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ authenticated }), + }); + return; + } + if (request.method() === "POST") { + const payload = request.postDataJSON() as { api_access_key?: string }; + authenticated = payload.api_access_key === "local-dev-key"; + await route.fulfill({ + status: authenticated ? 200 : 401, + contentType: "application/json", + body: JSON.stringify( + authenticated + ? { authenticated: true } + : { error: "Unauthorized" }, + ), + }); + return; + } + await route.fulfill({ status: 405 }); + }); + await page.route("**/api/cases", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + cases: [ + { + id: "case_article_1", + case_type: "article", + title: "伟思德鲁官网文章优化案例", + summary: "优化后摘要", + status: "optimized", + customer_name: "", + brand_name: "", + project_tags: [], + notes: "", + publish_target: "official_site", + source_excerpt: "原文摘要", + result_excerpt: "优化后摘要", + latest_result_version_id: "ver_article_1", + latest_version_number: 1, + last_error_stage: null, + last_error_summary: null, + archived_at: null, + created_at: "2026-07-08T10:00:00.000Z", + updated_at: "2026-07-08T10:05:00.000Z", + }, + ], + }), + }); + }); + + await page.goto("/cases"); + await expect(page.getByText("请先登录后查看案例库。")).toBeVisible(); + await page.getByLabel("访问密钥").fill("local-dev-key"); + await page.getByRole("button", { name: "确认登录" }).click(); + await expect(page.getByText("已登录")).toBeVisible(); + await expect(page.getByText("伟思德鲁官网文章优化案例")).toBeVisible(); +}); + +async function mockAuthenticatedSession( + page: Parameters[1]>[0]["page"], + authenticated: boolean, +) { + await page.route("**/api/auth/session", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ authenticated }), + }); + }); +}