Files
xiaoxia-saas/apps/web/e2e/test_auth.spec.ts
T
xiaoxia 0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
ci: Prettier纳入两层防御体系 (#520)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:08:19 +08:00

331 lines
11 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 认证流程 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 }) => {
// 使用一个伪造的过期 JWTheader.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())
})
})