Backend: - permission_handler: 完善权限 CRUD 接口(列表/创建/更新/删除) - auth_handler: 修复认证处理逻辑 - router: 新增权限管理路由 - handler_test: 新增权限 handler 测试覆盖 Frontend: - permissions.ts/test.ts: 权限服务层完整实现 - profile/settings/service_tests: 服务适配器修正 - client.ts: HTTP 客户端健壮性增强 - vite.config.js: 构建配置优化 - E2E 脚本: run-playwright-cdp-e2e 大幅增强(权限流程覆盖) Docs: - REAL_PROJECT_STATUS: 状态更新 - PRODUCTION_CHECKLIST/QUALITY_STANDARD/TECHNICAL_GUIDE/PROJECT_EXPERIENCE_SUMMARY: 团队规范完善 - plans/2026-04-23: 权限浏览器 CRUD 设计方案 验证: go build 0错误
383 lines
12 KiB
PowerShell
383 lines
12 KiB
PowerShell
param(
|
|
[string]$AdminUsername = 'e2e_admin',
|
|
[string]$AdminPassword = 'E2EAdmin@123456',
|
|
[string]$AdminEmail = 'e2e_admin@example.com',
|
|
[int]$BrowserPort = 0,
|
|
[int]$BackendPort = 0,
|
|
[int]$FrontendPort = 0
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
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, $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)
|
|
$listener.Start()
|
|
try {
|
|
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
|
|
} finally {
|
|
$listener.Stop()
|
|
}
|
|
}
|
|
|
|
function Test-UrlReady {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Url
|
|
)
|
|
|
|
try {
|
|
$response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2
|
|
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Wait-UrlReady {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Url,
|
|
[Parameter(Mandatory = $true)][string]$Label,
|
|
[int]$RetryCount = 120,
|
|
[int]$DelayMs = 500
|
|
)
|
|
|
|
for ($i = 0; $i -lt $RetryCount; $i++) {
|
|
if (Test-UrlReady -Url $Url) {
|
|
return
|
|
}
|
|
Start-Sleep -Milliseconds $DelayMs
|
|
}
|
|
|
|
throw "$Label did not become ready: $Url"
|
|
}
|
|
|
|
function Sync-AdminBootstrapExpectation {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$BackendBaseUrl
|
|
)
|
|
|
|
$capabilitiesUrl = "$BackendBaseUrl/api/v1/auth/capabilities"
|
|
$response = Invoke-RestMethod -Uri $capabilitiesUrl -Method Get -TimeoutSec 15
|
|
$requiresBootstrap = $false
|
|
|
|
if ($response -and $response.data -and $null -ne $response.data.admin_bootstrap_required) {
|
|
$requiresBootstrap = [bool]$response.data.admin_bootstrap_required
|
|
}
|
|
|
|
if ($requiresBootstrap) {
|
|
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
|
|
} else {
|
|
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap"
|
|
}
|
|
|
|
function Start-ManagedProcess {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[Parameter(Mandatory = $true)][string]$FilePath,
|
|
[string[]]$ArgumentList = @(),
|
|
[Parameter(Mandatory = $true)][string]$WorkingDirectory
|
|
)
|
|
|
|
$stdoutPath = Join-Path $env:TEMP "$Name-stdout.log"
|
|
$stderrPath = Join-Path $env:TEMP "$Name-stderr.log"
|
|
Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
|
|
|
|
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
|
|
$process = Start-Process `
|
|
-FilePath $FilePath `
|
|
-ArgumentList $ArgumentList `
|
|
-WorkingDirectory $WorkingDirectory `
|
|
-PassThru `
|
|
-WindowStyle Hidden `
|
|
-RedirectStandardOutput $stdoutPath `
|
|
-RedirectStandardError $stderrPath
|
|
} else {
|
|
$process = Start-Process `
|
|
-FilePath $FilePath `
|
|
-WorkingDirectory $WorkingDirectory `
|
|
-PassThru `
|
|
-WindowStyle Hidden `
|
|
-RedirectStandardOutput $stdoutPath `
|
|
-RedirectStandardError $stderrPath
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Name = $Name
|
|
Process = $process
|
|
StdOut = $stdoutPath
|
|
StdErr = $stderrPath
|
|
}
|
|
}
|
|
|
|
function Stop-ManagedProcess {
|
|
param(
|
|
[Parameter(Mandatory = $false)]$Handle
|
|
)
|
|
|
|
if (-not $Handle) {
|
|
return
|
|
}
|
|
|
|
if ($Handle.Process -and -not $Handle.Process.HasExited) {
|
|
try {
|
|
taskkill /PID $Handle.Process.Id /T /F *> $null
|
|
} catch {
|
|
Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
|
|
function Show-ManagedProcessLogs {
|
|
param(
|
|
[Parameter(Mandatory = $false)]$Handle
|
|
)
|
|
|
|
if (-not $Handle) {
|
|
return
|
|
}
|
|
|
|
if (Test-Path $Handle.StdOut) {
|
|
Get-Content $Handle.StdOut -ErrorAction SilentlyContinue
|
|
}
|
|
if (Test-Path $Handle.StdErr) {
|
|
Get-Content $Handle.StdErr -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Remove-ManagedProcessLogs {
|
|
param(
|
|
[Parameter(Mandatory = $false)]$Handle
|
|
)
|
|
|
|
if (-not $Handle) {
|
|
return
|
|
}
|
|
|
|
Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
$backendHandle = $null
|
|
$frontendHandle = $null
|
|
$smtpHandle = $null
|
|
$selectedBackendPort = if ($BackendPort -gt 0) { $BackendPort } else { Get-FreeTcpPort }
|
|
$selectedFrontendPort = if ($FrontendPort -gt 0) { $FrontendPort } else { Get-FreeTcpPort }
|
|
$selectedSMTPPort = Get-FreeTcpPort
|
|
$backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
|
|
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
|
|
|
|
try {
|
|
Push-Location $projectRoot
|
|
try {
|
|
$env:GOCACHE = $goCacheDir
|
|
$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: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'
|
|
|
|
Write-Host "playwright e2e backend: $backendBaseUrl"
|
|
Write-Host "playwright e2e frontend: $frontendBaseUrl"
|
|
Write-Host "playwright e2e smtp: 127.0.0.1:$selectedSMTPPort"
|
|
Write-Host "playwright e2e sqlite: $e2eDbPath"
|
|
|
|
$smtpHandle = Start-ManagedProcess `
|
|
-Name 'ums-smtp-capture' `
|
|
-FilePath 'node' `
|
|
-ArgumentList @((Join-Path $PSScriptRoot 'mock-smtp-capture.mjs'), '--port', "$selectedSMTPPort", '--output', $smtpCaptureFile) `
|
|
-WorkingDirectory $frontendRoot
|
|
|
|
Start-Sleep -Milliseconds 500
|
|
if ($smtpHandle.Process -and $smtpHandle.Process.HasExited) {
|
|
Show-ManagedProcessLogs $smtpHandle
|
|
throw 'smtp capture server failed to start'
|
|
}
|
|
|
|
$backendHandle = Start-ManagedProcess `
|
|
-Name 'ums-backend-playwright' `
|
|
-FilePath $serverExePath `
|
|
-WorkingDirectory $projectRoot
|
|
|
|
try {
|
|
Wait-UrlReady -Url "$backendBaseUrl/health" -Label 'backend'
|
|
} catch {
|
|
Show-ManagedProcessLogs $backendHandle
|
|
throw
|
|
}
|
|
|
|
$env:VITE_API_PROXY_TARGET = $backendBaseUrl
|
|
$env:VITE_API_BASE_URL = '/api/v1'
|
|
$frontendHandle = Start-ManagedProcess `
|
|
-Name 'ums-frontend-playwright' `
|
|
-FilePath 'npm.cmd' `
|
|
-ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', "$selectedFrontendPort") `
|
|
-WorkingDirectory $frontendRoot
|
|
|
|
try {
|
|
Wait-UrlReady -Url $frontendBaseUrl -Label 'frontend'
|
|
} catch {
|
|
Show-ManagedProcessLogs $frontendHandle
|
|
throw
|
|
}
|
|
|
|
$env:E2E_LOGIN_USERNAME = $AdminUsername
|
|
$env:E2E_LOGIN_PASSWORD = $AdminPassword
|
|
$env:E2E_LOGIN_EMAIL = $AdminEmail
|
|
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
|
|
$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
|
|
$suiteAttempts = 3
|
|
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 {
|
|
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
|
|
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
|
|
-Port $BrowserPort `
|
|
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
|
|
$lastError = $null
|
|
break
|
|
} catch {
|
|
$lastError = $_
|
|
if ($attempt -ge $suiteAttempts) {
|
|
throw
|
|
}
|
|
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
|
|
Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
|
|
Start-Sleep -Seconds 1
|
|
}
|
|
}
|
|
|
|
if ($lastError) {
|
|
throw $lastError
|
|
}
|
|
} 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 {
|
|
Stop-ManagedProcess $frontendHandle
|
|
Remove-ManagedProcessLogs $frontendHandle
|
|
Stop-ManagedProcess $backendHandle
|
|
Remove-ManagedProcessLogs $backendHandle
|
|
Stop-ManagedProcess $smtpHandle
|
|
Remove-ManagedProcessLogs $smtpHandle
|
|
Remove-Item Env:SERVER_PORT -ErrorAction SilentlyContinue
|
|
Remove-Item Env:DATABASE_DBNAME -ErrorAction SilentlyContinue
|
|
Remove-Item Env:SERVER_MODE -ErrorAction SilentlyContinue
|
|
Remove-Item Env:SERVER_FRONTEND_URL -ErrorAction SilentlyContinue
|
|
Remove-Item Env:CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue
|
|
Remove-Item Env:LOGGING_OUTPUT -ErrorAction SilentlyContinue
|
|
Remove-Item Env:EMAIL_HOST -ErrorAction SilentlyContinue
|
|
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: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
|
|
}
|