Files
crawler-plugin/admin-frontend-vue/tests/live/task-40-live.test.ts
T
huangzd1997 289f4488f9 fix(auth/logout): 禁用内置 LogoutFilter,登出改走 auth 模块清会话 cookie
POST /logout 原先被 Spring Security 内置 LogoutFilter 截走(204),只清空
SecurityContext,从不执行 LoginController.logout,导致 aiimage_token cookie
残留、退出登录后仍保持登录态。SecurityConfig 显式 logout.disable() 后请求
落到 POST /logout → 200 + Set-Cookie aiimage_token=;(Max-Age=0) 清除。
补充真实登出 E2E(live):登出返回 200、下发清除 cookie、无 cookie 后 401。
注:此前的 500/CNFE 系运行进程 jar 被覆盖为 thin jar 所致,重建 fat jar
并重启即恢复;本提交只改逻辑。11/11 live 用例通过。
2026-09-05 14:49:52 +08:00

160 lines
7.7 KiB
TypeScript
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.
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseCurrentUser, parseMenuTree } from '../../src/api/session-model.ts'
import { roleKind } from '../../src/types/admin.ts'
const BASE = process.env.AIIMAGE_LIVE_BASE || 'http://127.0.0.1:18080'
const USER = process.env.AIIMAGE_LIVE_USER || ''
const PASS = process.env.AIIMAGE_LIVE_PASS || ''
const HAS_CREDS = Boolean(USER && PASS)
const DEVICE = 'claude-task40-smoke'
let sessionCookie = ''
let bearerToken = ''
/** 登录一次并按浏览器 Cookie 语义缓存会话(真实登录态)。 */
async function ensureSession(): Promise<void> {
if (sessionCookie || bearerToken) return
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, deviceId: DEVICE }),
})
const body = (await res.json()) as { success?: boolean; message?: string; data?: { token?: string } }
assert.equal(body.success, true, `登录应成功: ${body.message || ''}`)
if (body.data?.token) bearerToken = body.data.token
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
const setCookie = setCookies[0] || res.headers.get('set-cookie') || ''
const match = /aiimage_token=([^;]+)/.exec(setCookie)
if (match) sessionCookie = `aiimage_token=${match[1]}`
assert.ok(sessionCookie || bearerToken, '登录应下发会话 cookie 或 token')
}
/** 模拟同源 Cookie 会话:带会话 cookie 或 Bearer 兜底请求受保护端点。 */
async function authedFetch(path: string): Promise<Response> {
const headers: Record<string, string> = {}
if (sessionCookie) headers.Cookie = sessionCookie
else if (bearerToken) headers.Authorization = `Bearer ${bearerToken}`
return fetch(`${BASE}${path}`, { headers })
}
test('test_task_040_live_smoke_normal_primary_path', { skip: !HAS_CREDS }, async () => {
// 正常主路径:真实登录后凭会话取当前用户。
await ensureSession()
const res = await authedFetch('/api/admin/current-user')
const user = parseCurrentUser(await res.json())
assert.equal(typeof user.id, 'number')
assert.equal(user.username, USER)
assert.equal(roleKind(user.role), 'super_admin')
})
test('test_task_040_live_smoke_normal_variant_input', { skip: !HAS_CREDS }, async () => {
// 正常变体:super_admin 菜单树含后台页面 key,路由级权限可放行。
await ensureSession()
const res = await authedFetch('/api/admin/current-user/menus')
const tree = parseMenuTree(await res.json())
const keys = new Set<string>()
const walk = (nodes: { key: string; children?: unknown }[] | undefined): void => {
for (const n of nodes || []) {
keys.add(n.key)
walk((n.children || []) as { key: string; children?: unknown }[])
}
}
walk(tree)
for (const expected of ['admin_users', 'admin_columns', 'admin_group_manage']) {
assert.ok(keys.has(expected), `菜单树应含后台页面 key: ${expected}`)
}
})
test('test_task_040_live_smoke_normal_repeated_operation_is_idempotent', { skip: !HAS_CREDS }, async () => {
// 正常重复:重复拉取用户/菜单结果稳定、不重排。
await ensureSession()
const first = parseCurrentUser(await (await authedFetch('/api/admin/current-user')).json())
const second = parseCurrentUser(await (await authedFetch('/api/admin/current-user')).json())
assert.equal(first.id, second.id)
const menusA = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
const menusB = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
assert.equal(JSON.stringify(menusA), JSON.stringify(menusB))
})
test('test_task_040_live_smoke_boundary_empty_input', { skip: !HAS_CREDS }, async () => {
// 边界空值:无会话直接请求受保护端点 → 401,前端据此跳登录。
const res = await fetch(`${BASE}/api/admin/current-user`)
const body = (await res.json()) as { success?: boolean; code?: number; message?: string }
assert.equal(body.success, false)
assert.equal(body.code, 401)
})
test('test_task_040_live_smoke_boundary_single_item', { skip: !HAS_CREDS }, async () => {
// 边界单元素:登录返回单会话 cookie 且可被管理员端点接受。
await ensureSession()
assert.ok(sessionCookie || bearerToken, '已建立单会话')
const res = await authedFetch('/api/admin/current-user')
const user = parseCurrentUser(await res.json())
assert.equal(user.username, USER)
})
test('test_task_040_live_smoke_boundary_limit_or_missing_field', { skip: !HAS_CREDS }, async () => {
// 边界上限:super_admin 菜单树中的每个模块页面都带可进入的 route(路由级权限放行依据)。
await ensureSession()
const tree = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
const routes = new Set<string>()
const walk = (nodes: { key: string; route?: string; children?: unknown }[] | undefined): void => {
for (const n of nodes || []) {
if (n.route) routes.add(n.route)
walk((n.children || []) as { key: string; route?: string; children?: unknown }[])
}
}
walk(tree)
for (const page of ['/account/users', '/account/menus', '/account/groups']) {
assert.ok(routes.has(page), `菜单树应含可进入页面 route: ${page}`)
}
})
test('test_task_040_live_smoke_invalid_input_rejected', { skip: !HAS_CREDS }, async () => {
// 异常输入:伪造 token 被拒绝,返回 401 而非业务数据。
const res = await fetch(`${BASE}/api/admin/current-user`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
const body = (await res.json()) as { success?: boolean; code?: number }
assert.equal(body.success, false)
assert.equal(body.code, 401)
})
test('test_task_040_live_smoke_dependency_failure_returns_actionable_message', { skip: !HAS_CREDS }, async () => {
// 依赖失败:真实负载经同一解析器/角色归一后仍符合前端契约(保序、角色可判)。
await ensureSession()
const tree = parseMenuTree(await (await authedFetch('/api/admin/current-user/menus')).json())
assert.ok(Array.isArray(tree) && tree.length > 0)
assert.equal(typeof tree[0].name, 'string')
const user = parseCurrentUser(await (await authedFetch('/api/admin/current-user')).json())
assert.ok(roleKind(user.role) !== null)
})
test('test_task_040_live_smoke_logout_clears_cookie', { skip: !HAS_CREDS }, async () => {
// 真实登出 E2EPOST /logout 下发清除 aiimage_token 的 Set-Cookie;清除后同源请求回到 401。
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, deviceId: `${DEVICE}-e2e-logout` }),
})
const loginBody = (await login.json()) as { success?: boolean; data?: { token?: string } }
assert.equal(loginBody.success, true)
const token = loginBody.data?.token || ''
assert.ok(token)
const cookie = `aiimage_token=${token}`
const out = await fetch(`${BASE}/logout`, {
method: 'POST',
headers: { Cookie: cookie, 'X-Requested-With': 'XMLHttpRequest' },
})
assert.equal(out.status, 200, '登出应返回 200 而非内置 LogoutFilter 的 204/500')
assert.equal(((await out.json()) as { success?: boolean }).success, true)
const clearCookies = typeof out.headers.getSetCookie === 'function' ? out.headers.getSetCookie() : []
const clearHeader = clearCookies[0] || out.headers.get('set-cookie') || ''
assert.match(clearHeader, /aiimage_token=;/, '登出应清除 aiimage_token cookie')
assert.match(clearHeader, /Max-Age=0|Expires=Thu, 01 Jan 1970/)
const after = (await (await fetch(`${BASE}/api/admin/current-user`)).json()) as { success?: boolean; code?: number }
assert.equal(after.success, false)
assert.equal(after.code, 401)
})