/** * 认证流程 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()) }) })