feat(admin): art-design-pro integration with local session auth
P1b Task 9: apps/admin based on art-design-pro v3.0.2 (tag, commit d224dd4; source recorded in apps/admin/SOURCE.md), trimmed to layout/login/i18n/request/table-form core. Login wired to real POST /v1/auth/password (single-token session), Bearer header on all requests, 401 interceptor logout+redirect, request-driven sliding renewal (refresh at ~80% TTL, single-flight), best-effort POST /v1/auth/logout on user-menu logout. One protected stub page (相册管理). Dev port 5176 strictPort + /v1 proxy to 8080. Admin keeps its own eslint9 flat config; root eslint ignores apps/admin/**. vitest unit tests for renewal pacing + Playwright e2e (guard redirect, localized wrong-password error, login lands on stub, token persists across reload, logout clears session). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
// e2e 全局夹具:幂等种子管理员账号(复用 services/api 官方路径)。
|
||||
// `pnpm --filter @photo/api-cn seed` 读仓库根 .env 的 ADMIN_ACCOUNT/ADMIN_PASSWORD;
|
||||
// 账号已存在则跳过且绝不改写密码(seed-admin.ts 语义),重复跑 e2e 安全。
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
const envFile = join(repoRoot, '.env')
|
||||
|
||||
export default function globalSetup(): void {
|
||||
if (!existsSync(envFile)) {
|
||||
throw new Error(
|
||||
`[admin e2e] 仓库根 .env 不存在(${envFile}):请参考 .env.example 配置开发环境`
|
||||
)
|
||||
}
|
||||
// shell:true 兼容 Windows(pnpm 是 .cmd)
|
||||
execSync('pnpm --filter @photo/api-cn seed', {
|
||||
cwd: dirname(envFile),
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// e2e 辅助:从仓库根 .env 读种子管理员凭据(global-setup 已跑过幂等 seed)。
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
export interface AdminCredentials {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function adminCredentials(): AdminCredentials {
|
||||
const envPath = join(repoRoot, '.env')
|
||||
if (!existsSync(envPath)) {
|
||||
throw new Error(`[admin e2e] 仓库根 .env 不存在(${envPath})`)
|
||||
}
|
||||
const vars = new Map<string, string>()
|
||||
for (const line of readFileSync(envPath, 'utf-8').split(/\r?\n/)) {
|
||||
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())
|
||||
if (match) vars.set(match[1], match[2])
|
||||
}
|
||||
const account = vars.get('ADMIN_ACCOUNT') ?? 'admin'
|
||||
const password = vars.get('ADMIN_PASSWORD') ?? 'admin-secret-change-me'
|
||||
return { account, password }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 登录/会话 e2e:真实后端(8080)+ admin dev(5176,/v1 代理)。
|
||||
// 覆盖:路由守卫重定向、错误密码本地化报错、正确登录落到相册管理 stub、
|
||||
// 令牌跨刷新持久(pinia persist → localStorage)、用户菜单登出(尽力吊销会话)。
|
||||
// 注意:后端登录限速 15 分钟窗口 5 次失败——本文件只做一次错误密码尝试,
|
||||
// 短时间内反复全量重跑可能触发 AUTH_RATE_LIMITED(属预期行为,等待窗口过去即可)。
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { adminCredentials } from './helpers'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 每个用例从干净会话开始(pinia persist 落在 localStorage)
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
})
|
||||
|
||||
test('未登录访问受保护路由 → 重定向登录页并携带 redirect', async ({ page }) => {
|
||||
await page.goto('/#/albums/manage')
|
||||
await expect(page.getByText('欢迎回来')).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/auth\/login\?redirect=/)
|
||||
})
|
||||
|
||||
test('错误密码 → 本地化错误提示(AUTH_INVALID_CREDENTIALS)', async ({ page }) => {
|
||||
const { account } = adminCredentials()
|
||||
await page.goto('/#/auth/login')
|
||||
await page.getByPlaceholder('请输入账号').fill(account)
|
||||
await page.getByPlaceholder('请输入密码', { exact: false }).fill('wrong-password-e2e')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await expect(page.getByText('账号或密码错误')).toBeVisible()
|
||||
// 仍停留在登录页
|
||||
await expect(page).toHaveURL(/\/auth\/login/)
|
||||
})
|
||||
|
||||
test('正确登录 → 落到相册管理占位页', async ({ page }) => {
|
||||
const { account, password } = adminCredentials()
|
||||
await page.goto('/#/auth/login')
|
||||
await page.getByPlaceholder('请输入账号').fill(account)
|
||||
await page.getByPlaceholder('请输入密码', { exact: false }).fill(password)
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
|
||||
// 首页 '/' 重定向到菜单第一个有效路径(/albums/manage)
|
||||
await expect(page).toHaveURL(/\/albums\/manage/)
|
||||
await expect(page.getByText('相册管理').first()).toBeVisible()
|
||||
await expect(page.getByText('相册管理功能建设中').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('登录后刷新页面 → 会话令牌持久,受保护路由仍可达', async ({ page }) => {
|
||||
const { account, password } = adminCredentials()
|
||||
await page.goto('/#/auth/login')
|
||||
await page.getByPlaceholder('请输入账号').fill(account)
|
||||
await page.getByPlaceholder('请输入密码', { exact: false }).fill(password)
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await expect(page).toHaveURL(/\/albums\/manage/)
|
||||
|
||||
// 会话令牌已持久化(pinia-plugin-persistedstate → localStorage,键含版本前缀)
|
||||
const storage = await page.evaluate(() => JSON.stringify(localStorage))
|
||||
expect(storage).toContain('sessionToken')
|
||||
|
||||
// 刷新后不弹回登录页:动态路由重建,受保护页仍加载
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/\/albums\/manage/)
|
||||
await expect(page.getByText('相册管理功能建设中').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('用户菜单登出 → 尽力吊销会话并回到登录页', async ({ page }) => {
|
||||
const { account, password } = adminCredentials()
|
||||
await page.goto('/#/auth/login')
|
||||
await page.getByPlaceholder('请输入账号').fill(account)
|
||||
await page.getByPlaceholder('请输入密码', { exact: false }).fill(password)
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await expect(page).toHaveURL(/\/albums\/manage/)
|
||||
|
||||
// 头像 hover 弹出用户菜单 → 退出登录 → 确认
|
||||
await page.getByAltText('avatar').hover()
|
||||
await page.getByText('退出登录').click()
|
||||
await page.getByRole('button', { name: '确定' }).click()
|
||||
|
||||
// 用 heading 断言:页面还残留登录成功的 ElNotification(「欢迎回来, ...」文案)
|
||||
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page).toHaveURL(/\/auth\/login/)
|
||||
// 本地会话已清空(user store 持久化键以 -user 结尾;字段保留但令牌为空串)
|
||||
const storedToken = await page.evaluate(() => {
|
||||
const entry = Object.entries(localStorage).find(([key]) => key.endsWith('-user'))
|
||||
return entry ? String(JSON.parse(entry[1]).sessionToken ?? '') : '__missing__'
|
||||
})
|
||||
expect(storedToken).toBe('')
|
||||
})
|
||||
Reference in New Issue
Block a user