/** * 认证流程 E2E 测试 * * 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息 * 每个测试独立,使用随机邮箱避免冲突。 */ import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; } function uniqueUsername(prefix: string): string { return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } /** 从错误响应中提取错误消息文本,兼容新老格式 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractErrorMessage(body: any): string { if (!body) return ""; // 新格式: { error: { code: "...", message: "..." } } if (body.error && typeof body.error === "object" && body.error.message) { return String(body.error.message); } // 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." } return String(body.detail || body.message || body.error || ""); } /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ async function loginWithRetry( request: APIRequestContext, email: string, password: string, maxRetries = 2, ) { for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, }); if (response.status() !== 429) return response; // 被限流了,等窗口重置 console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); await new Promise((r) => setTimeout(r, 65000)); } // 最后一次直接返回 return request.post(`${apiBase}/auth/login`, { data: { email, password }, }); } test.describe("认证流程", () => { // 登录限流 10次/60s,测试可能触发限流等待,给足够超时 test.describe.configure({ timeout: 180_000 }); // ─── 注册 ──────────────────────────────────────────── test("注册新用户 - 正向", async ({ request }) => { const email = uniqueEmail("reg-ok"); const username = uniqueUsername("regok"); const response = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "E2E 注册测试", }, }); expect( response.ok(), `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy(); const data = await response.json(); expect(data.user_id, "应返回 user_id").toBeTruthy(); expect(data.email).toBe(email); expect(data.username).toBe(username); }); test("注册已存在邮箱 - 反向", async ({ request }) => { const email = uniqueEmail("reg-dup"); const username1 = uniqueUsername("regdup1"); const username2 = uniqueUsername("regdup2"); // 第一次注册 const first = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username: username1, display_name: "User 1", }, }); expect(first.ok(), "第一次注册应成功").toBeTruthy(); // 第二次使用相同邮箱 const second = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username: username2, display_name: "User 2", }, }); expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual( 400, ); expect(second.status()).toBeLessThan(500); const body = await second.json(); // 错误信息应包含"已注册"或"exists"相关提示 const detail = extractErrorMessage(body).toLowerCase(); expect( detail.includes("已") || detail.includes("exist") || detail.includes("registered") || detail.includes("duplicate"), `错误信息应提示邮箱已注册,实际: "${detail}"`, ).toBeTruthy(); }); test("注册无效邮箱格式 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { data: { email: "not-an-email", password: PASSWORD, username: uniqueUsername("bademail"), display_name: "Bad Email", }, }); // 422 是 FastAPI 参数校验失败的标准状态码 expect([400, 422]).toContain(response.status()); }); test("注册弱密码 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { data: { email: uniqueEmail("weakpwd"), password: "123", username: uniqueUsername("weakpwd"), display_name: "Weak", }, }); expect([400, 422]).toContain(response.status()); }); test("注册用户名为空 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { data: { email: uniqueEmail("emptyuser"), password: PASSWORD, username: "", display_name: "Empty Username", }, }); expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain( response.status(), ); }); test("注册缺用户名字段 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { data: { email: uniqueEmail("nouser"), password: PASSWORD, display_name: "No Username Field", }, }); expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain( response.status(), ); }); // ─── 登录 ──────────────────────────────────────────── test("登录成功 - 正向", async ({ request }) => { const email = uniqueEmail("login-ok"); const username = uniqueUsername("loginok"); // 先注册 const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Login Test" }, }); expect(reg.ok(), "注册应成功").toBeTruthy(); // 登录(带限流重试) const response = await loginWithRetry(request, email, PASSWORD); expect( response.ok(), `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy(); const data = await response.json(); expect(data.access_token, "应返回 access_token").toBeTruthy(); expect(data.token_type).toBe("bearer"); expect(data.email).toBe(email); }); test("登录错误密码 - 反向", async ({ request }) => { const email = uniqueEmail("login-bad"); const username = uniqueUsername("loginbad"); // 先注册 await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Bad Login" }, }); // 使用错误密码登录(带限流重试) const response = await loginWithRetry(request, email, "WrongPassword999!"); expect(response.status(), "错误密码应返回 401").toBe(401); }); test("登录不存在的邮箱 - 反向", async ({ request }) => { // 带限流重试的反向登录测试 let response; for (let attempt = 0; attempt < 3; attempt++) { response = await request.post(`${apiBase}/auth/login`, { data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, }); if (response.status() !== 429) break; console.log( `[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`, ); await new Promise((r) => setTimeout(r, 65_000)); } expect(response.status(), "不存在的用户应返回 401").toBe(401); }); // ─── 登出 ──────────────────────────────────────────── test("登出成功", async ({ request }) => { const email = uniqueEmail("logout"); const username = uniqueUsername("logout"); // 注册 & 登录 await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Logout Test", }, }); const login = await loginWithRetry(request, email, PASSWORD); const { access_token } = await login.json(); const headers = { Authorization: `Bearer ${access_token}` }; // 登出 const logout = await request.post(`${apiBase}/auth/logout`, { headers }); expect( logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`, ).toBeTruthy(); const body = await logout.json(); expect(body.message).toBeTruthy(); // 登出后 token 应失效,尝试访问 /auth/me const me = await request.get(`${apiBase}/auth/me`, { headers }); expect([401, 403]).toContain(me.status()); }); // ─── 获取当前用户信息 ───────────────────────────────── test("获取当前用户信息 - 正向", async ({ request }) => { const email = uniqueEmail("me-ok"); const username = uniqueUsername("meok"); await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Me Test" }, }); const login = await loginWithRetry(request, email, PASSWORD); const { access_token } = await login.json(); const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${access_token}` }, }); expect( response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`, ).toBeTruthy(); const data = await response.json(); expect(data.user_id).toBeTruthy(); expect(data.email).toBe(email); expect(data.username).toBe(username); }); test("无 token 获取用户信息 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`); // HTTPBearer 无凭证返回 403 expect([401, 403]).toContain(response.status()); }); test("无效 token 获取用户信息 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: "Bearer invalid.token.here" }, }); expect(response.status()).toBe(401); }); test("过期 token 获取用户信息 - 反向", async ({ request }) => { // 使用一个伪造的过期 JWT(header.payload.signature) // eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"} // eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期) const expiredToken = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"; const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${expiredToken}` }, }); expect([401, 403]).toContain(response.status()); }); test("token 格式错误 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: "Bearer not-a-jwt" }, }); expect([401, 403]).toContain(response.status()); }); test("空 Bearer token - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: "Bearer " }, }); expect([401, 403]).toContain(response.status()); }); });