fix: close auth, permission, contract and e2e review blockers

This commit is contained in:
Your Name
2026-05-28 15:19:13 +08:00
parent f33e39a702
commit 6be90ddff8
29 changed files with 1356 additions and 259 deletions

View File

@@ -216,6 +216,7 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
$env:VITE_API_PROXY_TARGET = $backendBaseUrl
$env:VITE_API_BASE_URL = '/api/v1'
$env:NODE_ENV = 'development'
$frontendHandle = Start-ManagedProcess `
-Name 'ums-frontend-playwright' `
-FilePath 'npm.cmd' `
@@ -288,10 +289,11 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
Remove-Item Env:EMAIL_PORT -ErrorAction SilentlyContinue
Remove-Item Env:EMAIL_FROM_EMAIL -ErrorAction SilentlyContinue
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:JWT_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:DEFAULT_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:NODE_ENV -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 $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
ADMIN_USERNAME="${E2E_LOGIN_USERNAME:-e2e_admin}"
ADMIN_PASSWORD="${E2E_LOGIN_PASSWORD:-E2EAdmin@123456}"
ADMIN_EMAIL="${E2E_LOGIN_EMAIL:-e2e_admin@example.com}"
BOOTSTRAP_SECRET_VALUE="${E2E_BOOTSTRAP_SECRET:-${BOOTSTRAP_SECRET:-e2e-bootstrap-secret-0123456789abcdefghijklmnopqrstuvwxyz}}"
BROWSER_PORT="${E2E_CDP_PORT:-0}"
BACKEND_PORT="${E2E_BACKEND_PORT:-0}"
FRONTEND_PORT="${E2E_FRONTEND_PORT:-0}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TMP_ROOT="$(mktemp -d -t ums-playwright-e2e-XXXXXX)"
DATA_ROOT="$TMP_ROOT/data"
SMTP_CAPTURE_FILE="$TMP_ROOT/smtp-capture.jsonl"
SERVER_BIN="$TMP_ROOT/ums-server"
mkdir -p "$DATA_ROOT"
backend_pid=''
frontend_pid=''
smtp_pid=''
cleanup() {
local exit_code=$?
for pid in "$frontend_pid" "$backend_pid" "$smtp_pid"; do
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
rm -rf "$TMP_ROOT"
exit "$exit_code"
}
trap cleanup EXIT INT TERM
get_free_port() {
python3 - <<'PY'
import socket
s = socket.socket()
s.bind(('127.0.0.1', 0))
print(s.getsockname()[1])
s.close()
PY
}
wait_url_ready() {
local url="$1"
local label="$2"
local attempts="${3:-120}"
local delay="${4:-0.5}"
for ((i=0; i<attempts; i++)); do
if curl -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep "$delay"
done
echo "$label did not become ready: $url" >&2
return 1
}
SELECTED_BACKEND_PORT="$BACKEND_PORT"
if [[ "$SELECTED_BACKEND_PORT" == "0" ]]; then
SELECTED_BACKEND_PORT="$(get_free_port)"
fi
SELECTED_FRONTEND_PORT="$FRONTEND_PORT"
if [[ "$SELECTED_FRONTEND_PORT" == "0" ]]; then
SELECTED_FRONTEND_PORT="$(get_free_port)"
fi
SELECTED_SMTP_PORT="$(get_free_port)"
BACKEND_BASE_URL="http://127.0.0.1:${SELECTED_BACKEND_PORT}"
FRONTEND_BASE_URL="http://127.0.0.1:${SELECTED_FRONTEND_PORT}"
SQLITE_PATH="$DATA_ROOT/user_management.e2e.db"
cd "$PROJECT_ROOT"
go build -o "$SERVER_BIN" ./cmd/server
echo "playwright e2e backend: $BACKEND_BASE_URL"
echo "playwright e2e frontend: $FRONTEND_BASE_URL"
echo "playwright e2e smtp: 127.0.0.1:$SELECTED_SMTP_PORT"
echo "playwright e2e sqlite: $SQLITE_PATH"
node "$SCRIPT_DIR/mock-smtp-capture.mjs" --port "$SELECTED_SMTP_PORT" --output "$SMTP_CAPTURE_FILE" >"$TMP_ROOT/smtp.log" 2>&1 &
smtp_pid=$!
sleep 0.5
if ! kill -0 "$smtp_pid" 2>/dev/null; then
cat "$TMP_ROOT/smtp.log" >&2 || true
echo "smtp capture server failed to start" >&2
exit 1
fi
(
export SERVER_PORT="$SELECTED_BACKEND_PORT"
export DATABASE_DBNAME="$SQLITE_PATH"
export SERVER_MODE='debug'
export SERVER_FRONTEND_URL="$FRONTEND_BASE_URL"
export CORS_ALLOWED_ORIGINS="$FRONTEND_BASE_URL,http://localhost:${SELECTED_FRONTEND_PORT}"
export LOGGING_OUTPUT='stdout'
export DISABLE_RATE_LIMIT='1'
export EMAIL_HOST='127.0.0.1'
export EMAIL_PORT="$SELECTED_SMTP_PORT"
export EMAIL_FROM_EMAIL='noreply@test.local'
export EMAIL_FROM_NAME='UMS E2E'
export JWT_SECRET='e2e-test-jwt-secret-at-least-32-bytes-long-for-security'
export BOOTSTRAP_SECRET="$BOOTSTRAP_SECRET_VALUE"
exec "$SERVER_BIN"
) >"$TMP_ROOT/backend.log" 2>&1 &
backend_pid=$!
if ! wait_url_ready "$BACKEND_BASE_URL/health" 'backend'; then
cat "$TMP_ROOT/backend.log" >&2 || true
exit 1
fi
(
cd "$FRONTEND_ROOT"
export VITE_API_PROXY_TARGET="$BACKEND_BASE_URL"
export VITE_API_BASE_URL='/api/v1'
exec env -u NODE_ENV npm run dev -- --host 127.0.0.1 --port "$SELECTED_FRONTEND_PORT"
) >"$TMP_ROOT/frontend.log" 2>&1 &
frontend_pid=$!
if ! wait_url_ready "$FRONTEND_BASE_URL" 'frontend'; then
cat "$TMP_ROOT/frontend.log" >&2 || true
exit 1
fi
cd "$FRONTEND_ROOT"
export E2E_LOGIN_USERNAME="$ADMIN_USERNAME"
export E2E_LOGIN_PASSWORD="$ADMIN_PASSWORD"
export E2E_LOGIN_EMAIL="$ADMIN_EMAIL"
export E2E_BOOTSTRAP_SECRET="$BOOTSTRAP_SECRET_VALUE"
export BOOTSTRAP_SECRET="$BOOTSTRAP_SECRET_VALUE"
export E2E_EXPECT_ADMIN_BOOTSTRAP='1'
export E2E_EXTERNAL_WEB_SERVER='1'
export E2E_MANAGED_BROWSER='1'
export E2E_BASE_URL="$FRONTEND_BASE_URL"
export E2E_SMTP_CAPTURE_FILE="$SMTP_CAPTURE_FILE"
env -u NODE_ENV node ./scripts/run-playwright-cdp-e2e.mjs

View File

@@ -18,16 +18,18 @@ const TEXT = {
assignPermissions: '\u5206\u914d\u6743\u9650',
assignRoles: '\u5206\u914d\u89d2\u8272',
assignRolesAction: '\u89d2\u8272',
auditLogs: '\u5ba1\u8ba1\u65e5\u5fd7',
backToLogin: '\u8fd4\u56de\u767b\u5f55',
bootstrapAdminConfirmPasswordPlaceholder: '\u786e\u8ba4\u7ba1\u7406\u5458\u5bc6\u7801',
bootstrapAdminEmailPlaceholder: '\u7ba1\u7406\u5458\u90ae\u7bb1\uff08\u9009\u586b\uff09',
bootstrapAdminEmailPlaceholder: '\u7ba1\u7406\u5458\u90ae\u7bb1',
bootstrapAdminPasswordPlaceholder: '\u7ba1\u7406\u5458\u5bc6\u7801',
bootstrapAdminSecretPlaceholder: 'Bootstrap Secret',
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',
@@ -104,6 +107,7 @@ const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
let managedCdpUrl = null
const IS_WINDOWS = process.platform === 'win32'
function appUrl(pathname) {
return new URL(pathname, `${BASE_URL}/`).toString()
@@ -193,6 +197,16 @@ async function waitForActivationLink(email, timeoutMs = 20_000) {
throw new Error(`Timed out waiting for activation email for ${email}.`)
}
async function fetchAuthCapabilitiesSnapshot() {
const response = await fetch(appUrl('/api/v1/auth/capabilities'))
if (!response.ok) {
throw new Error(`Failed to fetch auth capabilities: ${response.status} ${response.statusText}`)
}
const payload = await response.json()
return payload?.data ?? {}
}
function resolveCdpUrl() {
if (managedCdpUrl) {
return managedCdpUrl
@@ -272,12 +286,24 @@ async function resolveManagedBrowserPath() {
return candidate
}
for (const candidate of [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
]) {
const platformCandidates = IS_WINDOWS
? [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
]
: [
'/snap/bin/chromium',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/microsoft-edge',
'/usr/bin/msedge',
]
for (const candidate of platformCandidates) {
try {
await assertFileExists(candidate)
return candidate
@@ -286,7 +312,9 @@ async function resolveManagedBrowserPath() {
}
}
const baseDir = path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright')
const baseDir = IS_WINDOWS
? path.join(process.env.LOCALAPPDATA ?? '', 'ms-playwright')
: path.join(process.env.HOME ?? '', '.cache', 'ms-playwright')
const candidates = []
try {
@@ -297,11 +325,16 @@ async function resolveManagedBrowserPath() {
}
candidates.push(
path.join(baseDir, entry.name, 'chrome-headless-shell-win64', 'chrome-headless-shell.exe'),
path.join(
baseDir,
entry.name,
IS_WINDOWS ? 'chrome-headless-shell-win64' : 'chrome-headless-shell-linux64',
IS_WINDOWS ? 'chrome-headless-shell.exe' : 'chrome-headless-shell',
),
)
}
} catch {
throw new Error('failed to scan Playwright browser cache under LOCALAPPDATA')
throw new Error(`failed to scan Playwright browser cache under ${baseDir}`)
}
candidates.sort().reverse()
@@ -376,6 +409,15 @@ async function killManagedBrowser(browserProcess) {
return
}
if (!IS_WINDOWS) {
try {
browserProcess.kill('SIGKILL')
} catch {
// ignore
}
return
}
await new Promise((resolve) => {
const killer = spawn('taskkill', ['/PID', String(browserProcess.pid), '/T', '/F'], {
stdio: 'ignore',
@@ -547,8 +589,28 @@ function attachSignalCollectors(page, signals) {
}
}
async function assertBaseUrlServesAdminApp(page) {
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('networkidle').catch(() => {})
const title = await page.title().catch(() => '')
const bodyText = (await page.locator('body').textContent())?.trim() ?? ''
const matchesAppTitle = title.includes(TEXT.appTitle)
const matchesAppBody = bodyText.includes(TEXT.welcomeLogin) || bodyText.includes(TEXT.adminBootstrapTitle)
if (matchesAppTitle || matchesAppBody) {
return
}
throw new Error(
`E2E_BASE_URL resolved to ${appUrl('/login')}, but the page does not look like the admin app. ` +
`title=${JSON.stringify(title)} body_excerpt=${JSON.stringify(bodyText.slice(0, 160))}. ` +
`Set E2E_BASE_URL to the running frontend app (default expects the Vite dev server on :3000).`,
)
}
async function resetBrowserState(context, page) {
logDebug('resetting browser state')
await page.setViewportSize({ width: VIEWPORTS[0].width, height: VIEWPORTS[0].height })
await context.clearCookies()
await page.goto(appUrl('/login'), { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
@@ -709,7 +771,12 @@ async function forceClick(locator) {
})
}
async function readRefreshToken(page) {
async function hasHttpOnlyRefreshCookie(page) {
const cookies = await page.context().cookies()
return cookies.some((cookie) => cookie.name === 'ums_refresh_token' && Boolean(cookie.value))
}
async function readSessionPresenceCookie(page) {
return await page.evaluate((cookieName) => {
const target = `${cookieName}=`
const matched = document.cookie
@@ -731,19 +798,31 @@ async function assertApiSuccessResponse(response, label) {
try {
payload = JSON.parse(responseBody)
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`${label} response is not valid JSON: ${responseBody}`)
}
throw error
throw new Error(`${label} response is not valid JSON: ${responseBody}`)
}
if (payload?.code !== 0) {
throw new Error(`${label} business response failed: ${responseBody}`)
throw new Error(`${label} response code ${payload?.code}: ${payload?.message ?? responseBody}`)
}
return payload
}
async function waitForSessionCookies(context, timeoutMs = 10_000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const cookies = await context.cookies()
const hasRefresh = cookies.some((cookie) => cookie.name === 'ums_refresh_token' && cookie.value)
const hasPresence = cookies.some((cookie) => cookie.name === 'ums_session_present' && cookie.value === '1')
if (hasRefresh && hasPresence) {
return
}
await delay(100)
}
throw new Error('session cookies were not persisted after login within timeout')
}
async function loginWithPassword(page, username, password, expectedUrlPattern) {
const usernameInput = page
.locator(`input[autocomplete="username"], input[placeholder="${TEXT.usernamePlaceholder}"]`)
@@ -761,12 +840,25 @@ async function loginWithPassword(page, username, password, expectedUrlPattern) {
if (loginResponse) {
await assertApiSuccessResponse(loginResponse, 'password login')
}
await waitForSessionCookies(page.context())
if (expectedUrlPattern) {
await expect(page).toHaveURL(expectedUrlPattern, { timeout: 30 * 1000 })
}
}
async function expectLoggedInLanding(page, timeoutMs = 30 * 1000) {
await expect(page).toHaveURL(/\/(dashboard|profile)$/, { timeout: timeoutMs })
const currentUrl = page.url()
if (currentUrl.endsWith('/dashboard')) {
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
return
}
await expect(page.locator('body')).toContainText(TEXT.profile)
}
async function loginFromLoginPage(page) {
const username = requireEnv('E2E_LOGIN_USERNAME')
const password = requireEnv('E2E_LOGIN_PASSWORD')
@@ -775,7 +867,8 @@ async function loginFromLoginPage(page) {
await expect(page).toHaveURL(/\/login$/)
await expect(page.getByRole('heading', { name: TEXT.welcomeLogin })).toBeVisible()
await loginWithPassword(page, username, password, /\/dashboard$/)
await loginWithPassword(page, username, password)
await expectLoggedInLanding(page)
return { username, password }
}
@@ -784,6 +877,10 @@ 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 = (process.env.E2E_BOOTSTRAP_SECRET ?? process.env.BOOTSTRAP_SECRET ?? '').trim()
if (!bootstrapSecret) {
throw new Error('E2E_BOOTSTRAP_SECRET or BOOTSTRAP_SECRET is required when E2E_EXPECT_ADMIN_BOOTSTRAP=1.')
}
const capabilitiesResponse = page.waitForResponse((response) => {
return response.url().includes('/api/v1/auth/capabilities') && response.request().method() === 'GET'
@@ -800,6 +897,7 @@ 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)
@@ -811,8 +909,7 @@ async function verifyAdminBootstrapWorkflow(page) {
])
await assertApiSuccessResponse(bootstrapResponse, 'bootstrap admin')
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30 * 1000 })
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible()
await expectLoggedInLanding(page)
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1012,7 +1109,8 @@ async function verifyAuthWorkflow(page) {
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
expect(await readRefreshToken(page)).toBeTruthy()
expect(await hasHttpOnlyRefreshCookie(page)).toBe(true)
expect(await readSessionPresenceCookie(page)).toBe('1')
const userRow = page.locator('tbody tr').filter({ hasText: credentials.username }).first()
await expect(userRow).toBeVisible({ timeout: 20 * 1000 })
@@ -1084,7 +1182,8 @@ async function verifyAuthWorkflow(page) {
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
await expect(page).toHaveURL(/\/login$/)
await expect(await readRefreshToken(page)).toBeNull()
await expect(await hasHttpOnlyRefreshCookie(page)).toBe(false)
await expect(await readSessionPresenceCookie(page)).toBeNull()
await page.goto(appUrl('/dashboard'))
const postLogoutRedirect = await getProtectedRouteRedirect(page)
@@ -1191,7 +1290,7 @@ async function verifyUserManagementCRUD(page) {
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')
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
const editResponsePromise = page.waitForResponse((response) => {
@@ -1202,7 +1301,7 @@ async function verifyUserManagementCRUD(page) {
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')
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
await expect(detailDrawer).toContainText(testUsername)
@@ -1211,13 +1310,14 @@ async function verifyUserManagementCRUD(page) {
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 10 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.delete }))
const deleteConfirmModal = page.locator('.ant-modal-confirm')
const deleteConfirmModal = page.locator('.ant-popover').filter({ hasText: '确定要删除用户' }).last()
await expect(deleteConfirmModal).toBeVisible({ timeout: 10 * 1000 })
const deleteResponsePromise = page.waitForResponse((response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'DELETE'
})
await forceClick(deleteConfirmModal.locator('.ant-btn-primary').last())
const deleteResponse = await deleteResponsePromise
const [deleteResponse] = await Promise.all([
page.waitForResponse((response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'DELETE'
}),
forceClick(deleteConfirmModal.locator('.ant-popconfirm-buttons .ant-btn-primary').last()),
])
await assertApiSuccessResponse(deleteResponse, 'delete user CRUD')
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toHaveCount(0, { timeout: 10 * 1000 })
@@ -1255,8 +1355,7 @@ 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 })
@@ -1270,11 +1369,11 @@ async function verifyLoginLogs(page) {
logDebug('verifyLoginLogs: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await expandSidebarGroup(page, TEXT.auditLogs)
await clickSidebarMenu(page, TEXT.loginLogs)
await expect(page).toHaveURL(/\/login-logs$/)
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 +1384,11 @@ async function verifyOperationLogs(page) {
logDebug('verifyOperationLogs: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await expandSidebarGroup(page, TEXT.auditLogs)
await clickSidebarMenu(page, TEXT.operationLogs)
await expect(page).toHaveURL(/\/operation-logs$/)
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 +1399,11 @@ async function verifyWebhookManagement(page) {
logDebug('verifyWebhookManagement: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.systemManagement)
await expandSidebarGroup(page, TEXT.integration)
await clickSidebarMenu(page, TEXT.webhooks)
await expect(page).toHaveURL(/\/webhooks$/)
await expect(page.getByText(TEXT.webhooks)).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('body')).toContainText('Webhook 管理', { timeout: 10 * 1000 })
await forceClick(page.locator('[class*="userTrigger"]'))
await forceClick(page.getByText(TEXT.logout, { exact: true }))
@@ -1322,10 +1421,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.locator('.ant-dropdown').getByText(TEXT.security, { exact: true }).last())
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 }))
@@ -1370,11 +1469,22 @@ async function main() {
throw new Error('No persistent Chromium context is available through CDP.')
}
const preflightPage = await ensurePersistentPage(browser, context)
if (!preflightPage) {
throw new Error('No persistent page is available in the Chromium CDP context.')
}
await assertBaseUrlServesAdminApp(preflightPage)
const authCapabilities = await fetchAuthCapabilitiesSnapshot()
if (process.env.E2E_EXPECT_ADMIN_BOOTSTRAP === '1') {
await runScenario(browser, context, 'admin-bootstrap', verifyAdminBootstrapWorkflow)
}
await runScenario(browser, context, 'public-registration', verifyPublicRegistration)
await runScenario(browser, context, 'email-activation', verifyEmailActivationWorkflow)
if (authCapabilities.email_activation) {
await runScenario(browser, context, 'email-activation', verifyEmailActivationWorkflow)
} else {
console.log('SKIP email-activation (auth capability disabled)')
}
await runScenario(browser, context, 'login-surface', verifyLoginSurface)
await runScenario(browser, context, 'auth-workflow', verifyAuthWorkflow)
await runScenario(browser, context, 'responsive-login', verifyResponsiveLogin)

View File

@@ -18,6 +18,7 @@ import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf'
import type { TokenBundle } from '@/types'
const DEFAULT_TIMEOUT = 30_000
let inFlightRefreshBundle: Promise<TokenBundle> | null = null
function isFormDataBody(body: unknown): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData
@@ -145,6 +146,40 @@ async function refreshAccessToken(): Promise<TokenBundle> {
return result.data
}
async function performTokenRefresh(): Promise<TokenBundle> {
if (inFlightRefreshBundle) {
return inFlightRefreshBundle
}
startRefreshing()
const promise = (async () => {
try {
const tokenBundle = await refreshAccessToken()
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
return tokenBundle
} finally {
endRefreshing()
clearRefreshPromise()
inFlightRefreshBundle = null
}
})()
inFlightRefreshBundle = promise
setRefreshPromise(
promise.then(
() => undefined,
() => undefined,
),
)
return promise
}
export async function refreshSessionBundle(): Promise<TokenBundle> {
return await performTokenRefresh()
}
async function performRefresh(): Promise<string> {
if (isRefreshing()) {
const promise = getRefreshPromise()
@@ -160,26 +195,8 @@ async function performRefresh(): Promise<string> {
return token
}
startRefreshing()
const promise = (async () => {
try {
const tokenBundle = await refreshAccessToken()
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
return tokenBundle.access_token
} finally {
endRefreshing()
clearRefreshPromise()
}
})()
setRefreshPromise(
promise.then(
() => undefined,
() => undefined,
),
)
return promise
const tokenBundle = await performTokenRefresh()
return tokenBundle.access_token
}
async function resolveAuthorizationHeader(auth: boolean): Promise<string | null> {

View File

@@ -345,14 +345,12 @@ export function ContactBindingsSection({
label="验证码"
rules={[{ required: true, message: '请输入验证码' }]}
>
<Input
placeholder="请输入验证码"
addonAfter={
<Button type="link" size="small" loading={sendCodeLoading} onClick={handleSendCode}>
</Button>
}
/>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder="请输入验证码" />
<Button type="link" loading={sendCodeLoading} onClick={handleSendCode}>
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="current_password" label="当前密码">

View File

@@ -29,7 +29,7 @@ const authContextValue: AuthContextValue = {
function renderBootstrapAdminPage() {
return render(
<MemoryRouter initialEntries={['/bootstrap-admin']}>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }} initialEntries={['/bootstrap-admin']}>
<AuthContext.Provider value={authContextValue}>
<BootstrapAdminPage />
</AuthContext.Provider>
@@ -88,7 +88,8 @@ 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_admin@example.com')
await user.type(screen.getByPlaceholderText('Bootstrap Secret'), 'bootstrap-secret-demo')
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
@@ -99,6 +100,7 @@ describe('BootstrapAdminPage', () => {
nickname: 'Bootstrap Admin',
email: 'bootstrap_admin@example.com',
password: 'Bootstrap123!@#',
bootstrap_secret: 'bootstrap-secret-demo',
}),
)

View File

@@ -24,7 +24,8 @@ const DEFAULT_CAPABILITIES: AuthCapabilities = {
type BootstrapAdminFormValues = {
username: string
nickname?: string
email?: string
email: string
bootstrapSecret: string
password: string
confirmPassword: string
}
@@ -71,7 +72,8 @@ export function BootstrapAdminPage() {
const tokenBundle = await bootstrapAdmin({
username: values.username.trim(),
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
email: values.email!.trim(),
bootstrap_secret: values.bootstrapSecret!.trim(),
password: values.password,
})
await onLoginSuccess(tokenBundle)
@@ -110,7 +112,7 @@ export function BootstrapAdminPage() {
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
Bootstrap Secret
</Paragraph>
<Alert
@@ -143,15 +145,29 @@ export function BootstrapAdminPage() {
</Form.Item>
<Form.Item
name="email"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
rules={[
{ required: true, message: '请输入管理员邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="管理员邮箱(选填)"
placeholder="管理员邮箱"
size="large"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="bootstrapSecret"
rules={[{ required: true, message: '请输入 Bootstrap Secret' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Bootstrap Secret"
size="large"
autoComplete="one-time-code"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入管理员密码' }]}

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', () => ({
@@ -61,7 +58,7 @@ vi.mock('@/services/auth', () => ({
function renderRegisterPage() {
return render(
<MemoryRouter initialEntries={['/register']}>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }} initialEntries={['/register']}>
<RegisterPage />
</MemoryRouter>,
)
@@ -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

@@ -38,10 +38,10 @@ type RegisterFormValues = {
confirmPassword: string
}
function buildRegisterSummary(result: RegisterResponse) {
if (result.user.status === 0) {
if (result.user.email) {
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
function buildRegisterSummary(user: RegisterResponse) {
if (user.status === 0) {
if (user.email) {
return `账号已创建,激活邮件会发送到 ${user.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

@@ -2,17 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const getMock = vi.fn()
const postMock = vi.fn()
const refreshSessionBundleMock = vi.fn()
vi.mock('@/lib/http/client', () => ({
get: getMock,
post: postMock,
refreshSessionBundle: refreshSessionBundleMock,
}))
describe('auth service', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
refreshSessionBundleMock.mockReset()
postMock.mockResolvedValue(undefined)
refreshSessionBundleMock.mockResolvedValue(undefined)
})
it('loads public auth capabilities without auth headers', async () => {
@@ -84,6 +88,28 @@ describe('auth service', () => {
)
})
it('verifies password-login totp with the temporary challenge token', async () => {
const { verifyTOTPAfterPasswordLogin } = await import('./auth')
await verifyTOTPAfterPasswordLogin({
user_id: 42,
code: '123456',
device_id: 'device-1',
temp_token: 'temp-token-demo',
})
expect(postMock).toHaveBeenCalledWith(
'/auth/login/totp-verify',
{
user_id: 42,
code: '123456',
device_id: 'device-1',
temp_token: 'temp-token-demo',
},
{ auth: false, credentials: 'include' },
)
})
it('submits public registration without auth headers', async () => {
const { register } = await import('./auth')
@@ -106,7 +132,7 @@ describe('auth service', () => {
)
})
it('submits first-admin bootstrap without auth headers', async () => {
it('submits first-admin bootstrap with bootstrap secret header', async () => {
const { bootstrapAdmin } = await import('./auth')
await bootstrapAdmin({
@@ -114,6 +140,7 @@ describe('auth service', () => {
password: 'Bootstrap123!@#',
email: 'bootstrap_admin@example.com',
nickname: 'Bootstrap Admin',
bootstrap_secret: 'bootstrap-secret-demo',
})
expect(postMock).toHaveBeenCalledWith(
@@ -124,7 +151,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-demo',
},
},
)
})
@@ -192,12 +225,13 @@ describe('auth service', () => {
expect(postMock).toHaveBeenCalledWith('/auth/logout', undefined, { credentials: 'include' })
})
it('refreshes the session with credentials even when no body token is supplied', async () => {
it('refreshes the session through the shared refresh single-flight when no body token is supplied', async () => {
const { refreshSession } = await import('./auth')
await refreshSession()
expect(postMock).toHaveBeenCalledWith(
expect(refreshSessionBundleMock).toHaveBeenCalledTimes(1)
expect(postMock).not.toHaveBeenCalledWith(
'/auth/refresh',
undefined,
{ auth: false, credentials: 'include' },

View File

@@ -1,4 +1,5 @@
import { get, post } from '@/lib/http/client'
import { refreshSessionBundle } from '@/lib/http/client'
import type {
ActionMessageResponse,
AuthCapabilities,
@@ -59,7 +60,14 @@ export function register(data: RegisterRequest): Promise<RegisterResponse> {
}
export function bootstrapAdmin(data: BootstrapAdminRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/bootstrap-admin', data, { auth: false, credentials: 'include' })
const { bootstrap_secret, ...payload } = data
return post<TokenBundle>('/auth/bootstrap-admin', payload, {
auth: false,
credentials: 'include',
headers: {
'X-Bootstrap-Secret': bootstrap_secret,
},
})
}
export function activateEmail(token: string): Promise<ActionMessageResponse> {
@@ -81,8 +89,11 @@ export function sendSmsCode(data: SendSmsCodeRequest): Promise<void> {
}
export function refreshSession(refreshToken?: string | null): Promise<TokenBundle> {
const body = refreshToken ? { refresh_token: refreshToken } : undefined
return post<TokenBundle>('/auth/refresh', body, { auth: false, credentials: 'include' })
if (!refreshToken) {
return refreshSessionBundle()
}
return post<TokenBundle>('/auth/refresh', { refresh_token: refreshToken }, { auth: false, credentials: 'include' })
}
export function getOAuthAuthorizationUrl(

View File

@@ -28,6 +28,29 @@ describe('social account service', () => {
expect(getMock).toHaveBeenCalledWith('/users/me/social-accounts')
})
it('normalizes object-wrapped social account payloads', async () => {
getMock.mockResolvedValue({
social_accounts: [
{
provider: 'github',
provider_user_id: '123',
provider_username: 'octocat',
bound_at: '2026-03-27 20:00:00',
},
],
})
const { listSocialAccounts } = await import('./social-accounts')
const result = await listSocialAccounts()
expect(result).toEqual([
expect.objectContaining({
provider: 'github',
provider_username: 'octocat',
}),
])
})
it('starts social binding with the current verification payload', async () => {
const { startSocialBinding } = await import('./social-accounts')

View File

@@ -6,8 +6,35 @@ import type {
SocialBindingStartResponse,
} from '@/types'
export function listSocialAccounts(): Promise<SocialAccountInfo[]> {
return get<SocialAccountInfo[]>('/users/me/social-accounts')
interface SocialAccountsResponse {
items?: SocialAccountInfo[]
accounts?: SocialAccountInfo[]
social_accounts?: SocialAccountInfo[]
}
function normalizeSocialAccounts(payload: SocialAccountInfo[] | SocialAccountsResponse): SocialAccountInfo[] {
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload.items)) {
return payload.items
}
if (Array.isArray(payload.accounts)) {
return payload.accounts
}
if (Array.isArray(payload.social_accounts)) {
return payload.social_accounts
}
return []
}
export async function listSocialAccounts(): Promise<SocialAccountInfo[]> {
const payload = await get<SocialAccountInfo[] | SocialAccountsResponse>('/users/me/social-accounts')
return normalizeSocialAccounts(payload)
}
export function startSocialBinding(

View File

@@ -20,6 +20,52 @@ describe('users service', () => {
delMock.mockReset()
})
it('normalizes backend user list payloads that use users/limit/offset fields', async () => {
getMock.mockResolvedValue({
users: [
{
id: 7,
username: 'e2e_admin',
email: 'admin@example.com',
nickname: '管理员',
status: '1',
},
],
total: 1,
limit: 20,
offset: 0,
})
const { listUsers } = await import('./users')
const result = await listUsers({ page: 1, page_size: 20 })
expect(getMock).toHaveBeenCalledWith('/users', { page: 1, page_size: 20 })
expect(result).toEqual({
items: [
{
id: 7,
username: 'e2e_admin',
email: 'admin@example.com',
phone: '',
nickname: '管理员',
avatar: '',
gender: 0,
birthday: '',
region: '',
bio: '',
status: 1,
last_login_at: '',
last_login_ip: '',
created_at: '',
updated_at: '',
},
],
total: 1,
page: 1,
page_size: 20,
})
})
it('creates a user through the protected users endpoint', async () => {
const payload = {
username: 'new-user',

View File

@@ -17,12 +17,59 @@ import type {
AssignUserRolesRequest,
} from '@/types/user'
interface RawUserListResponse {
items?: Partial<User>[]
users?: Partial<User>[]
total?: number
page?: number
page_size?: number
limit?: number
offset?: number
}
function normalizeUser(user: Partial<User>): User {
const numericStatus = typeof user.status === 'string' ? Number(user.status) : user.status
return {
id: user.id ?? 0,
username: user.username ?? '',
email: user.email ?? '',
phone: user.phone ?? '',
nickname: user.nickname ?? '',
avatar: user.avatar ?? '',
gender: user.gender ?? 0,
birthday: user.birthday ?? '',
region: user.region ?? '',
bio: user.bio ?? '',
status: (typeof numericStatus === 'number' && !Number.isNaN(numericStatus) ? numericStatus : 0) as UserStatus,
last_login_at: user.last_login_at ?? '',
last_login_ip: user.last_login_ip ?? '',
created_at: user.created_at ?? '',
updated_at: user.updated_at ?? '',
}
}
function normalizeUserListResponse(result?: RawUserListResponse | null): PaginatedData<User> {
const payload = result ?? {}
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.users) ? payload.users : []
const pageSize = payload.page_size ?? payload.limit ?? items.length
const offset = payload.offset ?? 0
const page = payload.page ?? (pageSize > 0 ? Math.floor(offset / pageSize) + 1 : 1)
return {
items: items.map(normalizeUser),
total: payload.total ?? items.length,
page,
page_size: pageSize,
}
}
/**
* 获取用户列表
* 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<RawUserListResponse>('/users', params as Record<string, string | number | boolean | undefined>)
return normalizeUserListResponse(result)
}
/**

View File

@@ -74,6 +74,44 @@ describe('webhooks service', () => {
expect(result.data[2].events).toEqual([])
})
it('normalizes backend webhook list payloads that use items/limit/offset fields', async () => {
getMock.mockResolvedValue({
items: [
{
id: 11,
name: 'Compat Hook',
url: 'https://example.com/compat',
events: '["user.updated"]',
status: 1,
max_retries: 3,
timeout_sec: 10,
created_by: 1,
created_at: '2026-03-27 20:20:00',
updated_at: '2026-03-27 20:20:00',
},
],
total: 1,
limit: 20,
offset: 0,
})
const { listWebhooks } = await import('./webhooks')
const result = await listWebhooks({ page: 1, page_size: 20 })
expect(result).toEqual({
data: [
expect.objectContaining({
id: 11,
name: 'Compat Hook',
events: ['user.updated'],
}),
],
total: 1,
page: 1,
page_size: 20,
})
})
it('sends create, update, delete, and delivery requests through the HTTP client', async () => {
postMock.mockResolvedValue({
id: 1,

View File

@@ -33,18 +33,42 @@ function normalizeWebhook(webhook: RawWebhook): Webhook {
}
interface PaginatedResponse<T> {
data: T[]
total: number
page: number
page_size: number
data?: T[]
items?: T[]
webhooks?: T[]
total?: number
page?: number
page_size?: number
limit?: number
offset?: number
}
function normalizeWebhookList(result: PaginatedResponse<RawWebhook>): { data: Webhook[]; total: number; page: number; page_size: number } {
const rawItems = Array.isArray(result.data)
? result.data
: Array.isArray(result.items)
? result.items
: Array.isArray(result.webhooks)
? result.webhooks
: []
const data = rawItems.map(normalizeWebhook)
const pageSize = result.page_size ?? result.limit ?? data.length
const offset = result.offset ?? 0
const page = result.page ?? (pageSize > 0 ? Math.floor(offset / pageSize) + 1 : 1)
return {
data,
total: result.total ?? data.length,
page,
page_size: pageSize,
}
}
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)
return { data: webhooks, total: result.total, page: result.page, page_size: result.page_size }
return normalizeWebhookList(result)
}
export function createWebhook(data: CreateWebhookRequest): Promise<Webhook> {