新增案例库浏览器登录态

This commit is contained in:
czj
2026-07-08 17:02:37 +08:00
parent fd1b792f2c
commit b4472fe348
12 changed files with 646 additions and 75 deletions
@@ -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),
});
}
+76
View File
@@ -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;
}
+16
View File
@@ -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;
@@ -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" },
});
}
+40 -11
View File
@@ -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<string, string> = { "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<ApiSessionResult> {
const response = await apiFetch("/api/auth/session");
return readSessionResponse(response);
}
export async function loginApiSession(
apiAccessKey: string,
): Promise<ApiSessionResult> {
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<ApiSessionResult> {
const response = await apiFetch("/api/auth/session", {
method: "DELETE",
});
return readSessionResponse(response);
}
async function readSessionResponse(response: Response): Promise<ApiSessionResult> {
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,
};
}
+122
View File
@@ -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<HTMLFormElement>) {
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 (
<div className="api-session-control" aria-label="访问密钥登录状态">
<span className="status-pill pass"></span>
<button
className="secondary-button"
disabled={isSubmitting}
onClick={logout}
type="button"
>
退
</button>
{message && message !== "已登录" ? <small>{message}</small> : null}
</div>
);
}
return (
<form className="api-session-control" onSubmit={submit}>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="current-password"
disabled={isChecking || isSubmitting}
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<button disabled={isChecking || isSubmitting} type="submit">
{isSubmitting ? "登录中..." : "确认登录"}
</button>
{message ? <small>{message}</small> : null}
</form>
);
}
+31 -24
View File
@@ -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<OptimizationCase["status"], string> = {
export function CaseDetail({ caseId }: CaseDetailProps) {
const [detail, setDetail] = useState<OptimizationCaseDetail | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(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<CaseDetailResponse>)
.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 (
<main className="app-shell">
<header className="topbar">
<div>
<h1>{detail?.case.title ?? "案例详情"}</h1>
{message ? <p>{message}</p> : detail ? <p>{detail.case.summary}</p> : null}
{visibleMessage ? (
<p>{visibleMessage}</p>
) : detail ? (
<p>{detail.case.summary}</p>
) : null}
</div>
<div className="topbar-actions">
<Link className="text-link" href="/cases">
</Link>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => updateApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<CaseAuthControl
authenticated={authenticated}
onAuthenticatedChange={updateAuthenticated}
/>
</div>
</header>
@@ -168,7 +176,6 @@ export function CaseDetail({ caseId }: CaseDetailProps) {
/>
)}
<CasePublicationPanel
apiAccessKey={apiAccessKey}
caseId={detail.case.id}
publishTarget={detail.case.publish_target}
versionId={selectedVersion?.id ?? null}
+27 -26
View File
@@ -1,14 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { OptimizationCase } from "../../lib/cases/types";
import {
apiHeaders,
readStoredApiAccessKey,
storeApiAccessKey,
} from "./api-client";
import { apiFetch } from "./api-client";
import { CaseAuthControl } from "./case-auth-control";
interface CaseListResponse {
cases?: OptimizationCase[];
@@ -33,7 +30,8 @@ export function CaseList() {
const [status, setStatus] = useState("");
const [query, setQuery] = useState("");
const [message, setMessage] = useState("");
const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey);
const [authenticated, setAuthenticated] = useState(false);
const [sessionChecked, setSessionChecked] = useState(false);
const search = useMemo(() => {
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<CaseListResponse>)
.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 (
<main className="app-shell">
<header className="topbar">
<div>
<h1></h1>
{message ? <p>{message}</p> : null}
{visibleMessage ? <p>{visibleMessage}</p> : null}
</div>
<div className="topbar-actions">
<Link className="text-link" href="/">
</Link>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => updateApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<CaseAuthControl
authenticated={authenticated}
onAuthenticatedChange={updateAuthenticated}
/>
</div>
</header>
<section className="case-toolbar" aria-label="案例筛选">
@@ -152,7 +153,7 @@ export function CaseList() {
<span>{formatDate(item.updated_at)}</span>
</Link>
))}
{cases.length === 0 ? (
{authenticated && cases.length === 0 ? (
<p className="empty-panel"></p>
) : null}
</section>
@@ -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<PublicationListResponse>)
.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,
+33 -1
View File
@@ -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);
});
});
+47 -2
View File
@@ -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<string, string> = {};
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);
}
+82
View File
@@ -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<Parameters<typeof test>[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 }),
});
});
}