fix: close auth, permission, contract and e2e review blockers
This commit is contained in:
@@ -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
|
||||
|
||||
142
frontend/admin/scripts/run-playwright-auth-e2e.sh
Normal file
142
frontend/admin/scripts/run-playwright-auth-e2e.sh
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user