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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
331 lines
11 KiB
TypeScript
Executable File
331 lines
11 KiB
TypeScript
Executable File
/**
|
||
* 认证流程 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())
|
||
})
|
||
})
|