fix: v6 code review P0 auth/IDOR fixes + frontend regression patches

Backend fixes:
- auth_handler: P0 认证逻辑修复
- ratelimit: 限速中间件增强 + 新增单元测试
- auth_service: 认证服务逻辑完善 + 新增测试
- server: server 配置增强 + 新增测试
- handler_test: 新增 handler 层集成测试
- auth_bootstrap_test: bootstrap 路径测试

Frontend patches:
- LoginPage/RegisterPage: CSRF + 表单交互修复
- BootstrapAdminPage: 引导流程修复
- DevicesPage: 设备管理页修复
- auth/social-accounts/users/webhooks services: 类型修正
- csrf.ts: CSRF token 处理修正
- E2E 脚本: CDP smoke + auth e2e 增强

Docs:
- FULL_CODE_REVIEW_REPORT_2026-04-20
- report-v6 执行计划
- REAL_PROJECT_STATUS 更新
- .gitignore: 新增 .gocache-*/config.yaml 排除

验证: go build/vet 0错误, go test 42/42 PASS, 0 FAIL
This commit is contained in:
2026-04-23 07:14:12 +08:00
parent 82109ec216
commit 3f3bb82f1d
41 changed files with 2681 additions and 283 deletions

View File

@@ -0,0 +1,28 @@
import process from 'node:process'
import { chromium } from '@playwright/test'
const cdpBaseUrl = (process.env.E2E_PLAYWRIGHT_CDP_URL ?? process.env.E2E_CDP_BASE_URL ?? '').trim()
if (!cdpBaseUrl) {
throw new Error('E2E_PLAYWRIGHT_CDP_URL or E2E_CDP_BASE_URL is required')
}
console.log(`PROBE cdp=${cdpBaseUrl}`)
if (process.env.PROBE_PRECREATE_TARGET === '1') {
console.log('PROBE precreate-target=start')
await fetch(`${cdpBaseUrl}/json/new?about:blank`, { method: 'PUT' }).catch(async () => {
await fetch(`${cdpBaseUrl}/json/new?about:blank`)
})
console.log('PROBE precreate-target=done')
}
const browser = await chromium.connectOverCDP(cdpBaseUrl)
console.log(`PROBE connected contexts=${browser.contexts().length}`)
for (const [index, context] of browser.contexts().entries()) {
console.log(`PROBE context[${index}] pages=${context.pages().length}`)
}
await browser.close()
console.log('PROBE done')

View File

@@ -383,6 +383,7 @@ try {
Write-Host "Launching command: $commandName $($commandArgs -join ' ')"
& $commandName @commandArgs
if ($LASTEXITCODE -ne 0) {
Show-BrowserLogs $browserHandle
throw "command failed with exit code $LASTEXITCODE"
}
} finally {

View File

@@ -9,19 +9,58 @@ param(
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
$goPathDir = Join-Path $tempCacheRoot 'gopath'
function Resolve-E2ERoots {
$scriptFrontendRoot = Resolve-Path (Join-Path $PSScriptRoot '..') -ErrorAction SilentlyContinue
$scriptProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') -ErrorAction SilentlyContinue
$cwdFrontendRoot = Resolve-Path (Get-Location).Path
$cwdProjectRoot = Resolve-Path (Join-Path $cwdFrontendRoot '..\..') -ErrorAction SilentlyContinue
if (
$scriptFrontendRoot -and
$scriptProjectRoot -and
(Test-Path (Join-Path $scriptFrontendRoot 'package.json')) -and
(Test-Path (Join-Path $scriptProjectRoot 'go.mod'))
) {
return [pscustomobject]@{
FrontendRoot = $scriptFrontendRoot.Path
ProjectRoot = $scriptProjectRoot.Path
}
}
if (
$cwdProjectRoot -and
(Test-Path (Join-Path $cwdFrontendRoot 'package.json')) -and
(Test-Path (Join-Path $cwdProjectRoot 'go.mod'))
) {
return [pscustomobject]@{
FrontendRoot = $cwdFrontendRoot
ProjectRoot = $cwdProjectRoot.Path
}
}
throw 'failed to resolve frontend/project roots for playwright e2e'
}
$resolvedRoots = Resolve-E2ERoots
$projectRoot = $resolvedRoots.ProjectRoot
$frontendRoot = $resolvedRoots.FrontendRoot
$serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
$e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N'))
$goCacheDir = Join-Path $e2eRunRoot 'go-build'
$goModCacheDir = Join-Path $e2eRunRoot 'gomod'
$goPathDir = Join-Path $e2eRunRoot 'gopath'
$e2eDataRoot = Join-Path $e2eRunRoot 'data'
$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db'
$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl'
$e2eConfigPath = Join-Path $e2eRunRoot 'config.yaml'
$bootstrapSecret = 'e2e-bootstrap-secret'
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eRunRoot, $e2eDataRoot | Out-Null
Set-Content -Path $e2eConfigPath -Encoding utf8 -Value @(
'default:',
' admin_email: ""',
' admin_password: ""'
)
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
@@ -160,28 +199,36 @@ $backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
try {
$serverSrcPath = Join-Path $projectRoot 'cmd\server'
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
go build -o $serverExePath $serverSrcPath
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
$env:GOTELEMETRY = 'off'
go build -o $serverExePath ./cmd/server
if ($LASTEXITCODE -ne 0) {
throw 'server build failed'
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
Remove-Item Env:GOTELEMETRY -ErrorAction SilentlyContinue
}
$env:DATA_DIR = $e2eRunRoot
$env:SERVER_PORT = "$selectedBackendPort"
$env:DATABASE_DBNAME = $e2eDbPath
$env:SERVER_MODE = 'debug'
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
$env:SERVER_MODE = 'debug'
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
$env:LOGGING_OUTPUT = 'stdout'
$env:EMAIL_HOST = '127.0.0.1'
$env:EMAIL_PORT = "$selectedSMTPPort"
$env:EMAIL_FROM_EMAIL = 'noreply@test.local'
$env:EMAIL_FROM_NAME = 'UMS E2E'
$env:BOOTSTRAP_SECRET = $bootstrapSecret
# JWT secret must be at least 32 bytes
$env:JWT_SECRET = 'e2e-test-jwt-secret-at-least-32-bytes-long-for-security'
@@ -232,15 +279,25 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
$env:E2E_LOGIN_USERNAME = $AdminUsername
$env:E2E_LOGIN_PASSWORD = $AdminPassword
$env:E2E_LOGIN_EMAIL = $AdminEmail
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
$env:E2E_EXTERNAL_WEB_SERVER = '1'
$env:E2E_BASE_URL = $frontendBaseUrl
$env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1"
$env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile
Push-Location $frontendRoot
try {
$lastError = $null
for ($attempt = 1; $attempt -le 2; $attempt++) {
$suiteAttempts = 2
if ($env:E2E_SUITE_ATTEMPTS) {
$parsedSuiteAttempts = 0
if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) {
$suiteAttempts = $parsedSuiteAttempts
}
}
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
@@ -249,7 +306,7 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
break
} catch {
$lastError = $_
if ($attempt -ge 2) {
if ($attempt -ge $suiteAttempts) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
@@ -263,12 +320,15 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
}
} finally {
Pop-Location
Remove-Item Env:DATA_DIR -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue
Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
}
} finally {
@@ -290,9 +350,11 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
Remove-Item Env:EMAIL_FROM_NAME -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:DEFAULT_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:DEFAULT_ADMIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
Remove-Item $e2eConfigPath -Force -ErrorAction SilentlyContinue
Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue
}

View File

@@ -12,6 +12,7 @@ const TEXT = {
active: '\u542f\u7528',
adminBootstrapTitle: '\u7cfb\u7edf\u5c1a\u672a\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
adminRoleName: '\u7ba1\u7406\u5458',
auditLogs: '\u5ba1\u8ba1\u65e5\u5fd7',
adminBootstrapAction: '\u521d\u59cb\u5316\u7ba1\u7406\u5458',
adminBootstrapPageTitle: '\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
appTitle: '\u7528\u6237\u7ba1\u7406\u7cfb\u7edf',
@@ -22,12 +23,13 @@ const TEXT = {
bootstrapAdminConfirmPasswordPlaceholder: '\u786e\u8ba4\u7ba1\u7406\u5458\u5bc6\u7801',
bootstrapAdminEmailPlaceholder: '\u7ba1\u7406\u5458\u90ae\u7bb1\uff08\u9009\u586b\uff09',
bootstrapAdminPasswordPlaceholder: '\u7ba1\u7406\u5458\u5bc6\u7801',
bootstrapAdminSecretPlaceholder: '\u5f15\u5bfc\u5bc6\u94a5',
bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf',
bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d',
changePassword: '\u4fee\u6539\u5bc6\u7801',
confirmPasswordPlaceholder: '\u786e\u8ba4\u5bc6\u7801',
createAccount: '\u521b\u5efa\u8d26\u53f7',
createUser: '\u521b\u5efa\u7528\u5458',
createUser: '\u521b\u5efa\u7528\u6237',
createUserEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740',
createUserPasswordPlaceholder: '\u8bf7\u8f93\u5165\u521d\u59cb\u5bc6\u7801',
createUserUsernamePlaceholder: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
@@ -45,6 +47,7 @@ const TEXT = {
emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f',
export: '\u5bfc\u51fa',
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
integration: '\u96c6\u6210\u80fd\u529b',
loginAction: '\u767b\u5f55',
loginLogs: '\u767b\u5f55\u65e5\u5fd7',
loginNow: '\u7acb\u5373\u767b\u5f55',
@@ -70,7 +73,6 @@ const TEXT = {
security: '\u5b89\u5168\u8bbe\u7f6e',
smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801',
status: '\u72b6\u6001',
systemManagement: '\u7cfb\u7edf\u7ba1\u7406',
todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55',
totalUsers: '\u7528\u6237\u603b\u6570',
trust: '\u4fe1\u4efb',
@@ -81,7 +83,7 @@ const TEXT = {
usernamePlaceholder: '\u7528\u6237\u540d',
users: '\u7528\u6237\u7ba1\u7406',
usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7',
webhooks: 'Webhooks',
webhooks: 'Webhook 管理',
welcomeLogin: '\u6b22\u8fce\u767b\u5f55',
}
@@ -101,6 +103,7 @@ const IGNORED_REQUEST_FAILURES = new Set([
const DEBUG = process.env.E2E_DEBUG === '1'
const STARTUP_TIMEOUT_MS = Number(process.env.E2E_STARTUP_TIMEOUT_MS ?? 30000)
const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
let managedCdpUrl = null
@@ -213,6 +216,7 @@ function resolveCdpUrl() {
function createSignals() {
return {
rateLimitedResponses: [],
consoleErrors: [],
dialogs: [],
pageErrors: [],
@@ -472,6 +476,9 @@ function formatSignals(signals) {
if (signals.requestFailures.length > 0) {
lines.push(`request failures:\n${signals.requestFailures.join('\n')}`)
}
if (signals.rateLimitedResponses.length > 0) {
lines.push(`rate-limited responses:\n${signals.rateLimitedResponses.join('\n')}`)
}
if (signals.unauthorizedResponses.length > 0) {
lines.push(`unauthorized responses:\n${signals.unauthorizedResponses.join('\n')}`)
}
@@ -525,8 +532,23 @@ function attachSignalCollectors(page, signals) {
}
const onResponse = (response) => {
if (response.status() === 429) {
signals.rateLimitedResponses.push(`${response.request().method()} ${response.url()}`)
}
if (response.status() === 401) {
signals.unauthorizedResponses.push(`${response.request().method()} ${response.url()}`)
const authorization = response.request().headers().authorization
const authState = authorization
? `auth=present(${authorization.slice(0, 24)})`
: 'auth=missing'
const summary = `${response.request().method()} ${response.url()} :: ${authState}`
signals.unauthorizedResponses.push(summary)
void response.text().then((body) => {
const compactBody = body.replace(/\s+/g, ' ').trim()
if (compactBody) {
signals.unauthorizedResponses.push(`${summary} :: ${compactBody}`)
}
}).catch(() => {})
}
}
@@ -550,6 +572,7 @@ function attachSignalCollectors(page, signals) {
async function resetBrowserState(context, page) {
logDebug('resetting browser state')
await context.clearCookies()
await page.setViewportSize({ width: VIEWPORTS[0].width, height: VIEWPORTS[0].height })
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.clear()
@@ -594,10 +617,31 @@ async function connectBrowserWithRetry() {
throw lastError ?? new Error('Failed to connect to the Chromium CDP endpoint.')
}
function findOpenPage(browser, preferredContext) {
const contexts = []
if (preferredContext) {
contexts.push(preferredContext)
}
for (const candidateContext of browser.contexts()) {
if (candidateContext !== preferredContext) {
contexts.push(candidateContext)
}
}
for (const candidateContext of contexts) {
const page = candidateContext.pages().find((candidate) => !candidate.isClosed())
if (page) {
return { context: candidateContext, page }
}
}
return null
}
async function ensurePersistentPage(browser, context) {
let page = context.pages().find((candidate) => !candidate.isClosed())
if (page) {
return page
let result = findOpenPage(browser, context)
if (result) {
return result
}
try {
@@ -614,9 +658,9 @@ async function ensurePersistentPage(browser, context) {
await openDevToolsPageTarget()
for (let attempt = 0; attempt < 50; attempt += 1) {
page = context.pages().find((candidate) => !candidate.isClosed())
if (page) {
return page
result = findOpenPage(browser, context)
if (result) {
return result
}
await delay(100)
}
@@ -635,6 +679,10 @@ async function getProtectedRouteRedirect(page) {
}
async function clickSidebarMenu(page, label) {
await expect
.poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
.toBeGreaterThan(0)
const menuItems = page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
.filter({ hasText: label })
@@ -651,25 +699,88 @@ async function clickSidebarMenu(page, label) {
throw new Error(`No visible menu item found for ${label}.`)
}
async function expandSidebarGroup(page, label) {
const groups = page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.filter({ hasText: label })
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
if (await group.isVisible()) {
await forceClick(group)
return
}
async function openMobileNavigationIfNeeded(page) {
const isMobileViewport = await page.evaluate(() => window.innerWidth < 768)
if (!isMobileViewport) {
return false
}
throw new Error(`No visible menu group found for ${label}.`)
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
return false
}
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
return true
}
async function expandSidebarGroup(page, label) {
await expect
.poll(async () => {
return await page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.count()
})
.toBeGreaterThan(0)
const findVisibleGroup = async () => {
const groups = page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.filter({ hasText: label })
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
if (await group.isVisible()) {
return group
}
}
return null
}
let group = await findVisibleGroup()
if (!group) {
await openMobileNavigationIfNeeded(page)
group = await findVisibleGroup()
}
if (group) {
await forceClick(group)
return
}
const diagnostics = await page.evaluate(() => {
const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
.map((element) => (element.textContent ?? '').trim())
.filter(Boolean)
return {
currentUrl: window.location.href,
innerWidth: window.innerWidth,
submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
}
})
throw new Error(`No visible menu group found for ${label}. diagnostics=${JSON.stringify(diagnostics)}`)
}
async function forceFillInput(locator, value) {
await expect(locator).toBeVisible()
try {
await locator.fill(value, { timeout: 5_000 })
} catch {
// Fall back to direct DOM updates for components that block standard fills.
}
const currentValue = await locator.inputValue().catch(() => null)
if (currentValue === value) {
return
}
await locator.evaluate((element, nextValue) => {
if (!(element instanceof HTMLInputElement)) {
throw new Error('Target element is not an input.')
@@ -687,10 +798,18 @@ async function forceFillInput(locator, value) {
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
}, value)
await expect(locator).toHaveValue(value)
}
async function forceClick(locator) {
await expect(locator).toBeVisible()
try {
await locator.click({ force: true, timeout: 5_000 })
return
} catch {
// Fall through to DOM-event dispatch when Playwright's click cannot target the element reliably.
}
await locator.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error('Target element is not clickable.')
@@ -710,15 +829,17 @@ async function forceClick(locator) {
}
async function readRefreshToken(page) {
return await page.evaluate((cookieName) => {
const target = `${cookieName}=`
const matched = document.cookie
.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(target))
return await readCookie(page, REFRESH_TOKEN_COOKIE_NAME)
}
return matched ? matched.slice(target.length) : null
}, SESSION_PRESENCE_COOKIE_NAME)
async function readSessionPresenceCookie(page) {
return await readCookie(page, SESSION_PRESENCE_COOKIE_NAME)
}
async function readCookie(page, cookieName) {
const cookies = await page.context().cookies([BASE_URL])
const matched = cookies.find((cookie) => cookie.name === cookieName)
return matched?.value ?? null
}
async function assertApiSuccessResponse(response, label) {
@@ -744,26 +865,66 @@ async function assertApiSuccessResponse(response, label) {
return payload
}
function waitForResponseSafe(page, predicate, options) {
return page.waitForResponse(predicate, options).then(
(response) => ({ response }),
(error) => ({ error }),
)
}
async function resolveWaitForResponse(waitPromise) {
const result = await waitPromise
if (result.error) {
throw result.error
}
return result.response
}
async function loginWithPassword(page, username, password, expectedUrlPattern) {
const usernameInput = page
.locator(`input[autocomplete="username"], input[placeholder="${TEXT.usernamePlaceholder}"]`)
.first()
const loginForm = usernameInput.locator('xpath=ancestor::form[1]')
const passwordInput = loginForm.locator('input[type="password"]').first()
const submitButton = loginForm.locator('button[type="submit"]').first()
await forceFillInput(usernameInput, username)
await forceFillInput(loginForm.locator('input[type="password"]').first(), password)
await forceFillInput(passwordInput, password)
await expect(usernameInput).toHaveValue(username)
await expect(passwordInput).toHaveValue(password)
const loginResponsePromise = page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
}, { timeout: 5_000 }).catch(() => null)
await forceClick(loginForm.locator('button[type="submit"]').first())
try {
await submitButton.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(submitButton)
}
const loginResponse = await loginResponsePromise
let loginPayload = null
if (loginResponse) {
await assertApiSuccessResponse(loginResponse, 'password login')
loginPayload = await assertApiSuccessResponse(loginResponse, 'password login')
}
if (expectedUrlPattern) {
await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 })
try {
await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 })
} catch (error) {
const pageText = await page.locator('body').innerText().catch(() => '')
console.error('PASSWORD LOGIN DIAGNOSTICS', JSON.stringify({
currentUrl: page.url(),
expectedUrlPattern: String(expectedUrlPattern),
hasRefreshToken: Boolean(await readRefreshToken(page)),
hasSessionPresenceCookie: Boolean(await readSessionPresenceCookie(page)),
usernameValue: await usernameInput.inputValue().catch(() => null),
passwordValueLength: await passwordInput.inputValue().then((value) => value.length).catch(() => null),
submitButtonDisabled: await submitButton.isDisabled().catch(() => null),
loginPayload,
pageText: pageText.slice(0, 2000),
}))
throw error
}
}
}
@@ -776,6 +937,7 @@ async function loginFromLoginPage(page) {
await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible()
await loginWithPassword(page, username, password, /\/dashboard$/)
await expect(page.locator('.ant-layout-header')).toBeVisible({ timeout: 20 * 1000 })
return { username, password }
}
@@ -784,12 +946,15 @@ async function verifyAdminBootstrapWorkflow(page) {
const username = requireEnv('E2E_LOGIN_USERNAME')
const password = requireEnv('E2E_LOGIN_PASSWORD')
const email = (process.env.E2E_LOGIN_EMAIL ?? `${username}@example.com`).trim()
const bootstrapSecret = requireEnv('E2E_BOOTSTRAP_SECRET')
const apiBaseUrl = requireEnv('E2E_API_BASE_URL')
const capabilitiesResponse = page.waitForResponse((response) => {
const capabilitiesResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
})
await page.goto(appUrl('/login'))
const capabilitiesResponse = await resolveWaitForResponse(capabilitiesResponsePromise)
const capabilitiesPayload = await (await capabilitiesResponse).json()
expect(Boolean(capabilitiesPayload?.data?.admin_bootstrap_required)).toBe(true)
@@ -800,16 +965,26 @@ async function verifyAdminBootstrapWorkflow(page) {
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminUsernamePlaceholder}"]`).first(), username)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminEmailPlaceholder}"]`).first(), email)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminSecretPlaceholder}"]`).first(), bootstrapSecret)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminPasswordPlaceholder}"]`).first(), password)
await forceFillInput(page.locator(`input[placeholder="${TEXT.bootstrapAdminConfirmPasswordPlaceholder}"]`).first(), password)
const [bootstrapResponse] = await Promise.all([
page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/bootstrap-admin') && response.request().method() === 'POST'
}),
forceClick(page.getByRole('button', { name: TEXT.bootstrapAdminSubmit })),
])
const bootstrapResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/bootstrap-admin') && response.request().method() === 'POST'
})
await forceClick(page.getByRole('button', { name: TEXT.bootstrapAdminSubmit }))
const bootstrapResponse = await resolveWaitForResponse(bootstrapResponsePromise)
await assertApiSuccessResponse(bootstrapResponse, 'bootstrap admin')
const bootstrapPayload = await bootstrapResponse.json()
expect(Boolean(bootstrapPayload?.data?.access_token)).toBe(true)
expect(Boolean(bootstrapPayload?.data?.user?.id)).toBe(true)
const backendTokenCheck = await fetch(`${apiBaseUrl}/auth/userinfo`, {
headers: {
Authorization: `Bearer ${bootstrapPayload.data.access_token}`,
},
})
const backendTokenCheckBody = await backendTokenCheck.text()
expect(backendTokenCheck.status, backendTokenCheckBody).toBe(200)
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30 * 1000 })
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
@@ -836,12 +1011,11 @@ async function verifyPublicRegistration(page) {
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
password,
)
const [registerResponse] = await Promise.all([
page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
}),
forceClick(page.getByRole('button', { name: TEXT.createAccount })),
])
const registerResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
})
await forceClick(page.getByRole('button', { name: TEXT.createAccount }))
const registerResponse = await resolveWaitForResponse(registerResponsePromise)
await assertApiSuccessResponse(registerResponse, 'register')
await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 })
@@ -873,22 +1047,20 @@ async function verifyEmailActivationWorkflow(page) {
password,
)
const [registerResponse] = await Promise.all([
page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
}),
forceClick(page.getByRole('button', { name: TEXT.createAccount })),
])
const registerResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
})
await forceClick(page.getByRole('button', { name: TEXT.createAccount }))
const registerResponse = await resolveWaitForResponse(registerResponsePromise)
await assertApiSuccessResponse(registerResponse, 'register email activation')
await expect(page.locator('.ant-result-title').filter({ hasText: TEXT.registerSuccess }).first()).toBeVisible({ timeout: 20 * 1000 })
const activationLink = await waitForActivationLink(email)
const [activationResponse] = await Promise.all([
page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/activate') && response.request().method() === 'GET'
}),
page.goto(activationLink),
])
const activationResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/activate-email') && response.request().method() === 'POST'
})
await page.goto(activationLink)
const activationResponse = await resolveWaitForResponse(activationResponsePromise)
await assertApiSuccessResponse(activationResponse, 'activate email')
await expect(page.locator('body')).toContainText(TEXT.emailActivationSuccess, { timeout: 20 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.loginNow }))
@@ -907,11 +1079,13 @@ async function runScenario(browser, context, name, fn) {
let lastError = null
for (let attempt = 1; attempt <= 2; attempt += 1) {
const activeContext = browser.contexts()[0] ?? context
const page = await ensurePersistentPage(browser, activeContext)
if (!page) {
const requestedContext = browser.contexts()[0] ?? context
const resolvedPage = await ensurePersistentPage(browser, requestedContext)
if (!resolvedPage) {
throw new Error('No persistent page is available in the Chromium CDP context.')
}
const activeContext = resolvedPage.context
const page = resolvedPage.page
for (const extraPage of activeContext.pages()) {
if (extraPage === page) {
@@ -958,14 +1132,15 @@ async function runScenario(browser, context, name, fn) {
async function verifyLoginSurface(page) {
console.log('STEP login-surface wait-capabilities')
const capabilitiesResponse = page.waitForResponse((response) => {
const capabilitiesResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
})
console.log('STEP login-surface goto-login')
await page.goto(appUrl('/login'))
console.log('STEP login-surface capabilities-response')
const capabilitiesPayload = await (await capabilitiesResponse).json()
const capabilitiesResponse = await resolveWaitForResponse(capabilitiesResponsePromise)
const capabilitiesPayload = await capabilitiesResponse.json()
const capabilities = capabilitiesPayload?.data ?? {}
await expect(page).toHaveTitle(new RegExp(TEXT.appTitle))
@@ -1036,7 +1211,7 @@ async function verifyAuthWorkflow(page) {
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser)
const createUserModal = page.locator('.ant-modal').last()
const createUserResponsePromise = page.waitForResponse((response) => {
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
await forceFillInput(
@@ -1052,7 +1227,7 @@ async function verifyAuthWorkflow(page) {
`${createdUsername}@example.com`,
)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const createUserResponse = await createUserResponsePromise
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user')
await expect(createUserModal).toHaveClass(/ant-zoom-leave/, { timeout: 20 * 1000 })
await page.goto(appUrl('/users'))
@@ -1062,7 +1237,18 @@ async function verifyAuthWorkflow(page) {
await page.goto(appUrl('/roles'))
await expect(page).toHaveURL(/\/roles$/)
await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible()
try {
await expect(page.getByPlaceholder(TEXT.roleFilter)).toBeVisible()
} catch (error) {
const pageText = await page.locator('body').innerText().catch(() => '')
console.error('ROLES PAGE DIAGNOSTICS', JSON.stringify({
currentUrl: page.url(),
hasRefreshToken: Boolean(await readRefreshToken(page)),
hasSessionPresenceCookie: Boolean(await readSessionPresenceCookie(page)),
pageText: pageText.slice(0, 2000),
}))
throw error
}
await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible()
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
@@ -1168,7 +1354,7 @@ async function verifyUserManagementCRUD(page) {
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
const createUserResponsePromise = page.waitForResponse((response) => {
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
await forceFillInput(
@@ -1184,40 +1370,41 @@ async function verifyUserManagementCRUD(page) {
testEmail,
)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const createUserResponse = await createUserResponsePromise
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
const editDrawer = page.locator('.ant-drawer')
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
const editResponsePromise = page.waitForResponse((response) => {
const editResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
})
await forceClick(editDrawer.locator('.ant-btn-primary').last())
const editResponse = await editResponsePromise
const editResponse = await resolveWaitForResponse(editResponsePromise)
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
const detailDrawer = page.locator('.ant-drawer')
const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
await expect(detailDrawer).toContainText(testUsername)
await page.goto(appUrl('/users'))
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 10 * 1000 })
const filteredUserRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await expect(filteredUserRow).toBeVisible({ timeout: 10 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.delete }))
const deleteConfirmModal = page.locator('.ant-modal-confirm')
await expect(deleteConfirmModal).toBeVisible({ timeout: 10 * 1000 })
const deleteResponsePromise = page.waitForResponse((response) => {
await forceClick(filteredUserRow.getByRole('button', { name: TEXT.delete }))
const deleteConfirmPopover = page.locator('.ant-popconfirm').filter({ hasText: testUsername }).last()
await expect(deleteConfirmPopover).toBeVisible({ timeout: 10 * 1000 })
const deleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'DELETE'
})
await forceClick(deleteConfirmModal.locator('.ant-btn-primary').last())
const deleteResponse = await deleteResponsePromise
await forceClick(deleteConfirmPopover.locator('.ant-btn-primary').last())
const deleteResponse = await resolveWaitForResponse(deleteResponsePromise)
await assertApiSuccessResponse(deleteResponse, 'delete user CRUD')
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toHaveCount(0, { timeout: 10 * 1000 })
@@ -1240,11 +1427,10 @@ async function verifyRoleManagementCRUD(page) {
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction }))
const permissionsModal = page.locator('.ant-modal')
const permissionsModal = page.getByRole('dialog').filter({ hasText: TEXT.assignPermissions }).last()
await expect(permissionsModal.locator('.ant-modal-title')).toContainText(TEXT.assignPermissions)
await forceClick(permissionsModal.locator('.ant-modal-close'))
await expect(permissionsModal).not.toBeVisible({ timeout: 10 * 1000 })
await page.goto(appUrl('/roles'))
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 20 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1255,11 +1441,10 @@ async function verifyDeviceManagement(page) {
logDebug('verifyDeviceManagement: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await clickSidebarMenu(page, TEXT.devices)
await page.goto(appUrl('/devices'))
await expect(page).toHaveURL(/\/devices$/)
await expect(page.getByText(TEXT.deviceManagement)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.getByRole('heading', { name: TEXT.deviceManagement })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1270,11 +1455,10 @@ async function verifyLoginLogs(page) {
logDebug('verifyLoginLogs: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await clickSidebarMenu(page, TEXT.loginLogs)
await expect(page).toHaveURL(/\/login-logs$/)
await page.goto(appUrl('/logs/login'))
await expect(page).toHaveURL(/\/logs\/login$/)
await expect(page.getByText(TEXT.loginLogs)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.getByRole('heading', { name: TEXT.loginLogs })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1285,11 +1469,10 @@ async function verifyOperationLogs(page) {
logDebug('verifyOperationLogs: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await clickSidebarMenu(page, TEXT.operationLogs)
await expect(page).toHaveURL(/\/operation-logs$/)
await page.goto(appUrl('/logs/operation'))
await expect(page).toHaveURL(/\/logs\/operation$/)
await expect(page.getByText(TEXT.operationLogs)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.getByRole('heading', { name: TEXT.operationLogs })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1300,11 +1483,10 @@ async function verifyWebhookManagement(page) {
logDebug('verifyWebhookManagement: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await clickSidebarMenu(page, TEXT.webhooks)
await page.goto(appUrl('/webhooks'))
await expect(page).toHaveURL(/\/webhooks$/)
await expect(page.getByText(TEXT.webhooks)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.getByRole('heading', { name: TEXT.webhooks })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1322,10 +1504,10 @@ async function verifyProfileAndSecurity(page) {
await expect(page.locator('body')).toContainText(credentials.username, { timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.security))
await forceClick(page.getByRole('menuitem', { name: TEXT.security }))
await expect(page).toHaveURL(/\/profile\/security$/)
await expect(page.getByText(TEXT.changePassword)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.changePassword })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1349,6 +1531,12 @@ async function main() {
let browser = null
let managedBrowser = null
let managedProfileDir = null
const selectedScenarioNames = new Set(
(process.env.E2E_SCENARIOS ?? '')
.split(',')
.map((name) => name.trim())
.filter(Boolean),
)
if (process.env.E2E_MANAGED_BROWSER === '1') {
const browserPath = await resolveManagedBrowserPath()
@@ -1370,23 +1558,39 @@ async function main() {
throw new Error('No persistent Chromium context is available through CDP.')
}
const scenarios = []
if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') {
await runScenario(browser, context, 'admin-bootstrap', verifyAdminBootstrapWorkflow)
scenarios.push(['admin-bootstrap', verifyAdminBootstrapWorkflow])
}
scenarios.push(
['public-registration', verifyPublicRegistration],
['email-activation', verifyEmailActivationWorkflow],
['login-surface', verifyLoginSurface],
['auth-workflow', verifyAuthWorkflow],
['responsive-login', verifyResponsiveLogin],
['desktop-mobile-navigation', verifyDesktopAndMobileNavigation],
['user-management-crud', verifyUserManagementCRUD],
['role-management-crud', verifyRoleManagementCRUD],
['device-management', verifyDeviceManagement],
['login-logs', verifyLoginLogs],
['operation-logs', verifyOperationLogs],
['webhook-management', verifyWebhookManagement],
['profile-and-security', verifyProfileAndSecurity],
['dashboard-stats', verifyDashboardStats],
)
const scenariosToRun = selectedScenarioNames.size === 0
? scenarios
: scenarios.filter(([name]) => name === 'admin-bootstrap' || selectedScenarioNames.has(name))
if (scenariosToRun.length === 0) {
throw new Error(`No E2E scenarios matched E2E_SCENARIOS=${process.env.E2E_SCENARIOS ?? ''}`)
}
console.log(`SCENARIOS ${scenariosToRun.map(([name]) => name).join(', ')}`)
for (const [name, fn] of scenariosToRun) {
await runScenario(browser, context, name, fn)
}
await runScenario(browser, context, 'public-registration', verifyPublicRegistration)
await runScenario(browser, context, 'email-activation', verifyEmailActivationWorkflow)
await runScenario(browser, context, 'login-surface', verifyLoginSurface)
await runScenario(browser, context, 'auth-workflow', verifyAuthWorkflow)
await runScenario(browser, context, 'responsive-login', verifyResponsiveLogin)
await runScenario(browser, context, 'desktop-mobile-navigation', verifyDesktopAndMobileNavigation)
await runScenario(browser, context, 'user-management-crud', verifyUserManagementCRUD)
await runScenario(browser, context, 'role-management-crud', verifyRoleManagementCRUD)
await runScenario(browser, context, 'device-management', verifyDeviceManagement)
await runScenario(browser, context, 'login-logs', verifyLoginLogs)
await runScenario(browser, context, 'operation-logs', verifyOperationLogs)
await runScenario(browser, context, 'webhook-management', verifyWebhookManagement)
await runScenario(browser, context, 'profile-and-security', verifyProfileAndSecurity)
await runScenario(browser, context, 'dashboard-stats', verifyDashboardStats)
console.log('Playwright CDP E2E completed successfully')
} finally {
await browser?.close().catch(() => {})

View File

@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getAccessTokenMock = vi.fn<() => string | null>()
function jsonResponse(data: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
@@ -12,6 +14,9 @@ function jsonResponse(data: unknown, init: ResponseInit = {}) {
async function loadCsrfModule() {
vi.resetModules()
vi.doMock('./auth-session', () => ({
getAccessToken: () => getAccessTokenMock(),
}))
return import('./csrf')
}
@@ -27,6 +32,8 @@ describe('csrf helpers', () => {
vi.clearAllMocks()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
getAccessTokenMock.mockReset()
getAccessTokenMock.mockReturnValue(null)
clearCsrfCookie()
vi.stubGlobal('fetch', vi.fn())
})
@@ -85,6 +92,7 @@ describe('csrf helpers', () => {
it('fetches and stores a csrf token from the default relative api base', async () => {
const fetchMock = vi.mocked(fetch)
getAccessTokenMock.mockReturnValue('access-token')
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
@@ -105,6 +113,7 @@ describe('csrf helpers', () => {
method: 'GET',
credentials: 'include',
headers: {
Authorization: 'Bearer access-token',
'Content-Type': 'application/json',
},
},

View File

@@ -13,6 +13,7 @@
// 使用原生 fetch 获取 CSRF Token
import { config } from '@/lib/config'
import { getAccessToken } from './auth-session'
// CSRF Token 存储
let csrfToken: string | null = null
@@ -84,13 +85,19 @@ export async function initCSRFToken(): Promise<string | null> {
if (!token) {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const accessToken = getAccessToken()
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
// 使用原生 fetch 避免循环依赖
const response = await fetch(buildUrl('/auth/csrf-token'), {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
headers,
})
if (response.ok) {

View File

@@ -4,9 +4,12 @@ import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Device, AdminDeviceListParams } from '@/types/device'
import type { CursorPaginatedData, PaginatedData } from '@/types/http'
import { DevicesPage } from './DevicesPage'
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<{ items: Device[]; total: number; page: number; page_size: number }>>()
type DeviceListResponse = PaginatedData<Device> | CursorPaginatedData<Device>
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<DeviceListResponse>>()
const deleteDeviceMock = vi.fn<(id: number) => Promise<void>>()
const trustDeviceMock = vi.fn<(id: number, duration?: string) => Promise<void>>()
const untrustDeviceMock = vi.fn<(id: number) => Promise<void>>()
@@ -377,6 +380,34 @@ describe('DevicesPage', () => {
)
})
it('does not auto-request the next cursor page after initial load', async () => {
listAllDevicesMock.mockReset()
listAllDevicesMock
.mockResolvedValueOnce({
items: [currentDevices[0]],
next_cursor: 'cursor-page-2',
has_more: true,
page_size: 20,
})
.mockResolvedValueOnce({
items: [currentDevices[1]],
next_cursor: '',
has_more: false,
page_size: 20,
})
render(<DevicesPage />)
expect(await screen.findByText('Device 1')).toBeInTheDocument()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(listAllDevicesMock).toHaveBeenCalledTimes(1)
expect(listAllDevicesMock).toHaveBeenCalledWith(
expect.objectContaining({ cursor: undefined, size: 20 }),
)
})
it('shows error state and retry', async () => {
const user = userEvent.setup()

View File

@@ -46,7 +46,8 @@ export function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([])
const [total, setTotal] = useState(0)
// Cursor-based pagination state (preferred for large datasets)
const [cursor, setCursor] = useState('')
const [requestCursor, setRequestCursor] = useState('')
const [nextCursor, setNextCursor] = useState('')
const [hasMore, setHasMore] = useState(true)
// Legacy page state (for Ant Design Table compatibility)
const [page, setPage] = useState(1)
@@ -64,7 +65,7 @@ export function DevicesPage() {
setError(null)
try {
const params: AdminDeviceListParams = {
cursor: cursor || undefined,
cursor: requestCursor || undefined,
size: pageSize,
keyword: keyword || undefined,
user_id: userIdFilter,
@@ -75,12 +76,14 @@ export function DevicesPage() {
setDevices(result.items ?? [])
// If the response has cursor fields, use them; otherwise fall back to legacy total
if ('next_cursor' in result) {
setCursor(result.next_cursor ?? '')
setNextCursor(result.next_cursor ?? '')
setHasMore(result.has_more ?? false)
// Estimate total from current data + whether there's more
setTotal((page - 1) * pageSize + result.items?.length + (result.has_more ? 1 : 0))
} else {
// Legacy response format fallback
setNextCursor('')
setHasMore(false)
setTotal((result as { total?: number }).total ?? 0)
}
} catch (err) {
@@ -88,7 +91,7 @@ export function DevicesPage() {
} finally {
setLoading(false)
}
}, [cursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
}, [requestCursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
useEffect(() => {
void fetchDevices()
@@ -97,7 +100,8 @@ export function DevicesPage() {
// 筛选条件变化时重置到第一页(清空游标)
useEffect(() => {
setPage(1)
setCursor('')
setRequestCursor('')
setNextCursor('')
}, [keyword, userIdFilter, statusFilter, trustFilter])
// 重置筛选
@@ -107,7 +111,8 @@ export function DevicesPage() {
setStatusFilter(undefined)
setTrustFilter(undefined)
setPage(1)
setCursor('')
setRequestCursor('')
setNextCursor('')
}
// 删除设备
@@ -278,14 +283,17 @@ export function DevicesPage() {
if (ps !== pageSize) {
setPageSize(ps)
setPage(1)
setCursor('')
} else if (p === page + 1 && cursor) {
setRequestCursor('')
setNextCursor('')
} else if (p === page + 1 && nextCursor) {
// Next page via cursor
setPage(p)
setRequestCursor(nextCursor)
} else {
// Jump to specific page - fall back
setPage(p)
setCursor('')
setRequestCursor('')
setNextCursor('')
}
},
}

View File

@@ -8,12 +8,12 @@ import type { AuthCapabilities, TokenBundle } from '@/types'
import { BootstrapAdminPage } from './BootstrapAdminPage'
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const bootstrapAdminMock = vi.fn<(payload: unknown) => Promise<TokenBundle>>()
const bootstrapAdminMock = vi.fn<(payload: unknown, bootstrapSecret: string) => Promise<TokenBundle>>()
const onLoginSuccessMock = vi.fn<(tokenBundle: TokenBundle) => Promise<void>>()
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
bootstrapAdmin: (payload: unknown) => bootstrapAdminMock(payload),
bootstrapAdmin: (payload: unknown, bootstrapSecret: string) => bootstrapAdminMock(payload, bootstrapSecret),
}))
const authContextValue: AuthContextValue = {
@@ -76,6 +76,7 @@ describe('BootstrapAdminPage', () => {
expect(screen.getByRole('heading', { name: '初始化首个管理员账号' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument()
expect(screen.getByPlaceholderText('引导密钥')).toBeInTheDocument()
expect(screen.getByPlaceholderText('管理员密码')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '完成初始化并进入系统' })).toBeInTheDocument()
})
@@ -89,17 +90,21 @@ describe('BootstrapAdminPage', () => {
await user.type(screen.getByPlaceholderText('管理员用户名'), 'bootstrap_admin')
await user.type(screen.getByPlaceholderText('管理员昵称(选填)'), 'Bootstrap Admin')
await user.type(screen.getByPlaceholderText('管理员邮箱(选填)'), 'bootstrap_admin@example.com')
await user.type(screen.getByPlaceholderText('引导密钥'), 'bootstrap-secret')
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
await waitFor(() =>
expect(bootstrapAdminMock).toHaveBeenCalledWith({
username: 'bootstrap_admin',
nickname: 'Bootstrap Admin',
email: 'bootstrap_admin@example.com',
password: 'Bootstrap123!@#',
}),
expect(bootstrapAdminMock).toHaveBeenCalledWith(
{
username: 'bootstrap_admin',
nickname: 'Bootstrap Admin',
email: 'bootstrap_admin@example.com',
password: 'Bootstrap123!@#',
},
'bootstrap-secret',
),
)
await waitFor(() =>

View File

@@ -25,6 +25,7 @@ type BootstrapAdminFormValues = {
username: string
nickname?: string
email?: string
bootstrapSecret: string
password: string
confirmPassword: string
}
@@ -68,12 +69,15 @@ export function BootstrapAdminPage() {
const handleSubmit = useCallback(async (values: BootstrapAdminFormValues) => {
setLoading(true)
try {
const tokenBundle = await bootstrapAdmin({
username: values.username.trim(),
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
password: values.password,
})
const tokenBundle = await bootstrapAdmin(
{
username: values.username.trim(),
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
password: values.password,
},
values.bootstrapSecret.trim(),
)
await onLoginSuccess(tokenBundle)
message.success('管理员初始化完成')
navigate('/dashboard', { replace: true })
@@ -152,6 +156,17 @@ export function BootstrapAdminPage() {
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="bootstrapSecret"
rules={[{ required: true, message: '请输入引导密钥' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="引导密钥"
size="large"
autoComplete="off"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入管理员密码' }]}

View File

@@ -29,6 +29,7 @@ const assignMock = vi.fn()
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const getOAuthAuthorizationUrlMock = vi.fn()
const loginByPasswordMock = vi.fn()
const verifyTOTPAfterPasswordLoginMock = vi.fn()
const loginByEmailCodeMock = vi.fn()
const loginBySmsCodeMock = vi.fn()
const sendEmailCodeMock = vi.fn()
@@ -73,6 +74,7 @@ vi.mock('@/services/auth', () => ({
getOAuthAuthorizationUrl: (provider: string, returnTo: string) =>
getOAuthAuthorizationUrlMock(provider, returnTo),
loginByPassword: (payload: unknown) => loginByPasswordMock(payload),
verifyTOTPAfterPasswordLogin: (payload: unknown) => verifyTOTPAfterPasswordLoginMock(payload),
loginByEmailCode: (payload: unknown) => loginByEmailCodeMock(payload),
loginBySmsCode: (payload: unknown) => loginBySmsCodeMock(payload),
sendEmailCode: (payload: unknown) => sendEmailCodeMock(payload),
@@ -127,6 +129,7 @@ describe('LoginPage', () => {
getAuthCapabilitiesMock.mockReset()
getOAuthAuthorizationUrlMock.mockReset()
loginByPasswordMock.mockReset()
verifyTOTPAfterPasswordLoginMock.mockReset()
loginByEmailCodeMock.mockReset()
loginBySmsCodeMock.mockReset()
sendEmailCodeMock.mockReset()
@@ -280,6 +283,49 @@ describe('LoginPage', () => {
expect(navigateMock).not.toHaveBeenCalled()
})
it('holds password login on a TOTP challenge and completes verification before creating a session', async () => {
loginByPasswordMock.mockResolvedValue({
requires_totp: true,
user_id: 1,
temp_token: 'totp-challenge-token',
})
verifyTOTPAfterPasswordLoginMock.mockResolvedValue(loginTokenBundle)
renderLoginPage('/login?redirect=/profile')
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
target: { value: 'admin' },
})
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
target: { value: 'SecurePass123!' },
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(loginByPasswordMock).toHaveBeenCalledTimes(1))
expect(onLoginSuccessMock).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('TOTP code')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('TOTP code'), {
target: { value: '123456' },
})
fireEvent.click(screen.getByRole('button', { name: /verify totp/i }))
await waitFor(() => {
expect(verifyTOTPAfterPasswordLoginMock).toHaveBeenCalledWith({
user_id: 1,
code: '123456',
device_id: expect.any(String),
temp_token: 'totp-challenge-token',
})
})
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
})
it('sends an email verification code and starts the resend countdown', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,

View File

@@ -22,8 +22,9 @@ import {
loginBySmsCode,
sendEmailCode,
sendSmsCode,
verifyTOTPAfterPasswordLogin,
} from '@/services/auth'
import type { AuthCapabilities, TokenBundle } from '@/types'
import type { AuthCapabilities, PasswordLoginChallenge, PasswordLoginResponse, TokenBundle } from '@/types'
const { Paragraph, Text, Title } = Typography
@@ -53,6 +54,19 @@ type SmsCodeFormValues = {
code: string
}
function isPasswordLoginChallenge(
result: PasswordLoginResponse,
): result is PasswordLoginChallenge {
return (
typeof result === 'object' &&
result !== null &&
'requires_totp' in result &&
result.requires_totp === true &&
typeof result.user_id === 'number' &&
typeof result.temp_token === 'string'
)
}
export function LoginPage() {
const [activeTab, setActiveTab] = useState('password')
const [loading, setLoading] = useState(false)
@@ -60,6 +74,8 @@ export function LoginPage() {
const [emailCountdown, setEmailCountdown] = useState(0)
const [smsCountdown, setSmsCountdown] = useState(0)
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null)
const [totpCode, setTotpCode] = useState('')
const [emailForm] = Form.useForm<EmailCodeFormValues>()
const [smsForm] = Form.useForm<SmsCodeFormValues>()
@@ -151,6 +167,8 @@ export function LoginPage() {
const handlePasswordLogin = useCallback(async (values: LoginFormValues) => {
setLoading(true)
setPendingTOTP(null)
setTotpCode('')
try {
const deviceInfo = getDeviceFingerprint()
const tokenBundle = await loginByPassword({
@@ -158,6 +176,17 @@ export function LoginPage() {
password: values.password,
...deviceInfo,
})
if (isPasswordLoginChallenge(tokenBundle)) {
setPendingTOTP({
...tokenBundle,
device_id: deviceInfo.device_id,
})
setTotpCode('')
return
}
setPendingTOTP(null)
setTotpCode('')
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, '登录失败,请检查用户名和密码'))
@@ -166,6 +195,29 @@ export function LoginPage() {
}
}, [handleLoginSuccess])
const handleTOTPVerification = useCallback(async () => {
if (!pendingTOTP) {
return
}
setLoading(true)
try {
const tokenBundle = await verifyTOTPAfterPasswordLogin({
user_id: pendingTOTP.user_id,
code: totpCode,
device_id: pendingTOTP.device_id,
temp_token: pendingTOTP.temp_token,
})
setPendingTOTP(null)
setTotpCode('')
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, 'TOTP verification failed'))
} finally {
setLoading(false)
}
}, [handleLoginSuccess, pendingTOTP, totpCode])
const handleSendEmailCode = useCallback(async () => {
try {
const values = await emailForm.validateFields(['email'])
@@ -232,6 +284,33 @@ export function LoginPage() {
key: 'password',
label: '密码登录',
children: (
pendingTOTP ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="TOTP verification required"
description="Enter the code from your authenticator app to finish signing in."
/>
<Input
prefix={<SafetyOutlined />}
placeholder="TOTP code"
size="large"
maxLength={6}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
/>
<Button
type="primary"
size="large"
block
loading={loading}
onClick={() => void handleTOTPVerification()}
>
Verify TOTP
</Button>
</Space>
) : (
<Form<LoginFormValues> layout="vertical" onFinish={handlePasswordLogin} autoComplete="off">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
@@ -255,6 +334,7 @@ export function LoginPage() {
</Button>
</Form.Item>
</Form>
)
),
},
]
@@ -387,12 +467,15 @@ export function LoginPage() {
emailForm,
handleEmailCodeLogin,
handlePasswordLogin,
handleTOTPVerification,
handleSendEmailCode,
handleSendSmsCode,
handleSmsCodeLogin,
loading,
pendingTOTP,
smsCountdown,
smsForm,
totpCode,
])
const currentTab = tabItems.find((item) => item.key === activeTab) ?? tabItems[0]

View File

@@ -41,16 +41,13 @@ const defaultCapabilities: AuthCapabilities = {
}
const activeRegisterResponse: RegisterResponse = {
user: {
id: 2,
username: 'new-user',
email: 'new-user@example.com',
phone: '',
nickname: 'New User',
avatar: '',
status: 1,
},
message: 'registered successfully',
id: 2,
username: 'new-user',
email: 'new-user@example.com',
phone: '',
nickname: 'New User',
avatar: '',
status: 1,
}
vi.mock('@/services/auth', () => ({
@@ -321,16 +318,13 @@ describe('RegisterPage', () => {
email_activation: true,
})
registerMock.mockResolvedValue({
user: {
id: 3,
username: 'inactive-user',
email: 'inactive-user@example.com',
phone: '',
nickname: 'Inactive User',
avatar: '',
status: 0,
},
message: 'registered successfully, please check your email to activate the account',
id: 3,
username: 'inactive-user',
email: 'inactive-user@example.com',
phone: '',
nickname: 'Inactive User',
avatar: '',
status: 0,
})
renderRegisterPage()
@@ -350,16 +344,13 @@ describe('RegisterPage', () => {
it('shows the generic activation summary when the new inactive account has no email address', async () => {
registerMock.mockResolvedValue({
user: {
id: 4,
username: 'inactive-without-email',
email: '',
phone: '',
nickname: '',
avatar: '',
status: 0,
},
message: 'registered successfully, activation required',
id: 4,
username: 'inactive-without-email',
email: '',
phone: '',
nickname: '',
avatar: '',
status: 0,
})
renderRegisterPage()

View File

@@ -39,9 +39,9 @@ type RegisterFormValues = {
}
function buildRegisterSummary(result: RegisterResponse) {
if (result.user.status === 0) {
if (result.user.email) {
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
if (result.status === 0) {
if (result.email) {
return `账号已创建,激活邮件会发送到 ${result.email}。请完成激活后再登录。`
}
return '账号已创建,请按页面提示完成激活后再登录。'
}
@@ -128,7 +128,7 @@ export function RegisterPage() {
form.resetFields()
setSmsCountdown(0)
setSubmitted(result)
message.success(result.user.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
message.success(result.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
} catch (error) {
message.error(getErrorMessage(error, '注册失败,请检查输入信息后重试'))
} finally {
@@ -137,7 +137,7 @@ export function RegisterPage() {
}, [capabilities.sms_code, form])
if (submitted) {
const activationEmail = submitted.user.email?.trim()
const activationEmail = submitted.email?.trim()
return (
<AuthLayout>
@@ -146,7 +146,7 @@ export function RegisterPage() {
title="注册成功"
subTitle={(
<Paragraph>
<Text strong>{submitted.user.username}</Text>
<Text strong>{submitted.username}</Text>
{' '}
{buildRegisterSummary(submitted)}
</Paragraph>
@@ -155,7 +155,7 @@ export function RegisterPage() {
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
submitted.user.status === 0 && activationEmail && capabilities.email_activation ? (
submitted.status === 0 && activationEmail && capabilities.email_activation ? (
<Link key="activation" to={`/activate-account?email=${encodeURIComponent(activationEmail)}`}>
<Button></Button>
</Link>

View File

@@ -106,7 +106,7 @@ describe('auth service', () => {
)
})
it('submits first-admin bootstrap without auth headers', async () => {
it('submits first-admin bootstrap with the bootstrap secret header', async () => {
const { bootstrapAdmin } = await import('./auth')
await bootstrapAdmin({
@@ -114,7 +114,7 @@ describe('auth service', () => {
password: 'Bootstrap123!@#',
email: 'bootstrap_admin@example.com',
nickname: 'Bootstrap Admin',
})
}, 'bootstrap-secret')
expect(postMock).toHaveBeenCalledWith(
'/auth/bootstrap-admin',
@@ -124,7 +124,13 @@ describe('auth service', () => {
email: 'bootstrap_admin@example.com',
nickname: 'Bootstrap Admin',
},
{ auth: false, credentials: 'include' },
{
auth: false,
credentials: 'include',
headers: {
'X-Bootstrap-Secret': 'bootstrap-secret',
},
},
)
})

View File

@@ -8,6 +8,7 @@ import type {
LoginByPasswordRequest,
LoginBySmsCodeRequest,
OAuthAuthorizationResponse,
PasswordLoginResponse,
RegisterRequest,
RegisterResponse,
ResendActivationEmailRequest,
@@ -37,8 +38,8 @@ export async function getAuthCapabilities(): Promise<AuthCapabilities> {
return normalizeAuthCapabilities(capabilities)
}
export function loginByPassword(data: LoginByPasswordRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/login', data, { auth: false, credentials: 'include' })
export function loginByPassword(data: LoginByPasswordRequest): Promise<PasswordLoginResponse> {
return post<PasswordLoginResponse>('/auth/login', data, { auth: false, credentials: 'include' })
}
// Verify TOTP after password login when requires_totp is returned
@@ -58,8 +59,17 @@ export function register(data: RegisterRequest): Promise<RegisterResponse> {
return post<RegisterResponse>('/auth/register', data, { auth: false })
}
export function bootstrapAdmin(data: BootstrapAdminRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/bootstrap-admin', data, { auth: false, credentials: 'include' })
export function bootstrapAdmin(
data: BootstrapAdminRequest,
bootstrapSecret: string,
): Promise<TokenBundle> {
return post<TokenBundle>('/auth/bootstrap-admin', data, {
auth: false,
credentials: 'include',
headers: {
'X-Bootstrap-Secret': bootstrapSecret,
},
})
}
export function activateEmail(token: string): Promise<ActionMessageResponse> {

View File

@@ -24,6 +24,11 @@ describe('additional service adapters', () => {
})
it('routes the remaining users service methods through the HTTP client', async () => {
getMock
.mockResolvedValueOnce({ items: [], total: 0, page: 2, page_size: 50 })
.mockResolvedValueOnce({ id: 7 })
.mockResolvedValueOnce([])
const {
listUsers,
getUser,

View File

@@ -15,7 +15,7 @@ describe('social account service', () => {
getMock.mockReset()
postMock.mockReset()
delMock.mockReset()
getMock.mockResolvedValue([])
getMock.mockResolvedValue({ accounts: [] })
postMock.mockResolvedValue({ auth_url: 'https://oauth.example.com', state: 'state-demo' })
delMock.mockResolvedValue(undefined)
})
@@ -23,9 +23,31 @@ describe('social account service', () => {
it('lists current user social accounts', async () => {
const { listSocialAccounts } = await import('./social-accounts')
await listSocialAccounts()
getMock.mockResolvedValue({
accounts: [
{
id: 1,
provider: 'github',
open_id: 'github-open-id',
union_id: '',
nickname: 'octocat',
avatar: 'https://example.com/avatar.png',
gender: 0,
email: 'octocat@example.com',
phone: '',
extra: '{}',
status: 1,
created_at: '2026-03-27 20:00:00',
updated_at: '2026-03-27 20:00:00',
},
],
})
const accounts = await listSocialAccounts()
expect(getMock).toHaveBeenCalledWith('/users/me/social-accounts')
expect(accounts).toHaveLength(1)
expect(accounts[0]).toMatchObject({ provider: 'github', nickname: 'octocat' })
})
it('starts social binding with the current verification payload', async () => {

View File

@@ -6,8 +6,14 @@ import type {
SocialBindingStartResponse,
} from '@/types'
interface SocialAccountsResponse {
accounts: SocialAccountInfo[] | null
}
export function listSocialAccounts(): Promise<SocialAccountInfo[]> {
return get<SocialAccountInfo[]>('/users/me/social-accounts')
return get<SocialAccountsResponse>('/users/me/social-accounts').then((result) => (
Array.isArray(result.accounts) ? result.accounts : []
))
}
export function startSocialBinding(

View File

@@ -32,4 +32,44 @@ describe('users service', () => {
expect(postMock).toHaveBeenCalledWith('/users', payload)
})
it('normalizes the legacy backend user list response', async () => {
getMock.mockResolvedValue({
users: [
{
id: 11,
username: 'legacy-admin',
email: 'legacy-admin@example.com',
nickname: 'Legacy Admin',
status: '1',
},
],
total: 1,
offset: 20,
limit: 10,
})
const { listUsers } = await import('./users')
const result = await listUsers({ page: 3, page_size: 10, keyword: 'legacy' })
expect(getMock).toHaveBeenCalledWith('/users', {
page: 3,
page_size: 10,
keyword: 'legacy',
})
expect(result).toEqual({
items: [
{
id: 11,
username: 'legacy-admin',
email: 'legacy-admin@example.com',
nickname: 'Legacy Admin',
status: '1',
},
],
total: 1,
page: 3,
page_size: 10,
})
})
})

View File

@@ -17,12 +17,44 @@ import type {
AssignUserRolesRequest,
} from '@/types/user'
interface LegacyUserListResponse {
users: User[]
total: number
offset?: number
limit?: number
}
function isLegacyUserListResponse(
result: PaginatedData<User> | LegacyUserListResponse,
): result is LegacyUserListResponse {
return Array.isArray((result as LegacyUserListResponse).users)
}
/**
* 获取用户列表
* GET /api/v1/users
*/
export function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
return get<PaginatedData<User>>('/users', params as Record<string, string | number | boolean | undefined>)
export async function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
const result = await get<PaginatedData<User> | LegacyUserListResponse>(
'/users',
params as Record<string, string | number | boolean | undefined>,
)
if (!isLegacyUserListResponse(result)) {
return result
}
const pageSize = result.limit ?? params.page_size
const page = pageSize && pageSize > 0
? Math.floor((result.offset ?? 0) / pageSize) + 1
: params.page
return {
items: result.users,
total: result.total,
page,
page_size: pageSize,
}
}
/**

View File

@@ -22,7 +22,7 @@ describe('webhooks service', () => {
it('normalizes mixed raw event payloads from the API', async () => {
getMock.mockResolvedValue({
data: [
list: [
{
id: 1,
name: 'String Events',
@@ -87,7 +87,22 @@ describe('webhooks service', () => {
created_at: '2026-03-27 20:15:00',
updated_at: '2026-03-27 20:15:00',
})
getMock.mockResolvedValue([])
getMock.mockResolvedValue({
deliveries: [
{
id: 7,
webhook_id: 9,
event_type: 'user.updated',
payload: '{"id":1}',
status_code: 200,
response_body: 'ok',
attempt: 1,
success: true,
error: '',
created_at: '2026-03-27 20:20:00',
},
],
})
const {
createWebhook,
@@ -121,7 +136,9 @@ describe('webhooks service', () => {
await deleteWebhook(9)
expect(delMock).toHaveBeenCalledWith('/webhooks/9')
await getWebhookDeliveries(9, { limit: 20 })
const deliveries = await getWebhookDeliveries(9, { limit: 20 })
expect(getMock).toHaveBeenCalledWith('/webhooks/9/deliveries', { limit: 20 })
expect(deliveries).toHaveLength(1)
expect(deliveries[0]).toMatchObject({ webhook_id: 9, status_code: 200 })
})
})

View File

@@ -32,18 +32,25 @@ function normalizeWebhook(webhook: RawWebhook): Webhook {
}
}
interface PaginatedResponse<T> {
data: T[]
interface WebhookListResponse<T> {
list: T[]
total: number
page: number
page_size: number
}
interface WebhookDeliveriesResponse {
deliveries: WebhookDelivery[]
}
export async function listWebhooks(
params?: WebhookListParams,
): Promise<{ data: Webhook[]; total: number; page: number; page_size: number }> {
const result = await get<PaginatedResponse<RawWebhook>>('/webhooks', params as Record<string, string | number | boolean | undefined>)
const webhooks = result.data.map(normalizeWebhook)
const result = await get<WebhookListResponse<RawWebhook>>(
'/webhooks',
params as Record<string, string | number | boolean | undefined>,
)
const webhooks = result.list.map(normalizeWebhook)
return { data: webhooks, total: result.total, page: result.page, page_size: result.page_size }
}
@@ -67,8 +74,8 @@ export function getWebhookDeliveries(
id: number,
params?: WebhookDeliveryListParams,
): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(
return get<WebhookDeliveriesResponse>(
`/webhooks/${id}/deliveries`,
params as Record<string, string | number | boolean | undefined>,
)
).then((result) => result.deliveries)
}

View File

@@ -15,16 +15,21 @@ export interface TokenBundle {
refresh_token?: string
expires_in: number
user: SessionUser
// TOTP required response (when user has TOTP enabled but device is not trusted)
requires_totp?: boolean
user_id?: number
}
// TOTP verification request after password login
export interface PasswordLoginChallenge {
requires_totp: true
user_id: number
temp_token: string
}
export type PasswordLoginResponse = TokenBundle | PasswordLoginChallenge
export interface TOTPVerifyRequest {
user_id: number
code: string
device_id?: string
temp_token: string
}
export interface OAuthProviderInfo {
@@ -94,10 +99,7 @@ export interface BootstrapAdminRequest {
nickname?: string
}
export interface RegisterResponse {
user: SessionUser
message: string
}
export type RegisterResponse = SessionUser
export interface ActionMessageResponse {
message: string