205 lines
16 KiB
PowerShell
205 lines
16 KiB
PowerShell
param(
|
|
[string]$PostgresBin = 'D:\pgsql17\bin',
|
|
[string]$PreparedPackageRoot = '',
|
|
[switch]$KeepTemporary,
|
|
[switch]$BrowserHold
|
|
)
|
|
Set-StrictMode -Version 3.0
|
|
$ErrorActionPreference = 'Stop'
|
|
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
|
$bellRoot = Join-Path $repositoryRoot 'Bell'
|
|
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('bell-e2e-' + [guid]::NewGuid().ToString('N'))
|
|
$pgData = Join-Path $temporary 'postgres'
|
|
$pgLog = Join-Path $temporary 'postgres.log'
|
|
$runtimeOut = Join-Path $temporary 'bell-launcher.out.log'
|
|
$runtimeErr = Join-Path $temporary 'bell-launcher.err.log'
|
|
$launcher = $null
|
|
$pgStarted = $false
|
|
$savedEnvironment = @{}
|
|
|
|
function Get-FreeTcpPort {
|
|
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0)
|
|
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
|
}
|
|
function Get-UniqueFreePorts([int]$Count) {
|
|
$ports = [Collections.Generic.List[int]]::new()
|
|
while ($ports.Count -lt $Count) { $port=Get-FreeTcpPort; if (-not $ports.Contains($port)) { $ports.Add($port) } }
|
|
return $ports.ToArray()
|
|
}
|
|
function New-RandomText([int]$Bytes=32) {
|
|
$buffer=New-Object byte[] $Bytes; $generator=[Security.Cryptography.RandomNumberGenerator]::Create()
|
|
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
|
|
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+','A').Replace('/','B')
|
|
}
|
|
function Set-TestEnvironment([string]$Name,[string]$Value) {
|
|
if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name]=[Environment]::GetEnvironmentVariable($Name,'Process') }
|
|
[Environment]::SetEnvironmentVariable($Name,$Value,'Process')
|
|
}
|
|
function Wait-Tcp([int]$Port,[bool]$Open,[int]$Attempts=120) {
|
|
for($i=0;$i -lt $Attempts;$i++) {
|
|
$client=[Net.Sockets.TcpClient]::new()
|
|
try { $connected=$client.ConnectAsync('127.0.0.1',$Port).Wait(250)-and$client.Connected } catch { $connected=$false } finally { $client.Dispose() }
|
|
if($connected -eq $Open){return}; Start-Sleep -Milliseconds 250
|
|
}
|
|
throw "TCP port $Port did not reach open=$Open"
|
|
}
|
|
function Wait-Health([string]$BaseUrl) {
|
|
for($i=0;$i -lt 120;$i++){try{$health=Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy;if($health.status-eq'ok'-and$health.service-eq'bell'){return}}catch{};Start-Sleep -Milliseconds 300}
|
|
throw "Bell health endpoint did not become ready: $BaseUrl"
|
|
}
|
|
function Invoke-BellJson {
|
|
param([string]$Method,[string]$Path,$Body=$null,[string]$Token='',[int]$ExpectedCode=200)
|
|
$headers=@{};if($Token){$headers.Authorization="Bearer $Token"}
|
|
$arguments=@{Method=$Method;Uri="$script:baseUrl$Path";Headers=$headers;TimeoutSec=20;NoProxy=$true}
|
|
if($null-ne$Body){$arguments.ContentType='application/json; charset=utf-8';$arguments.Body=$Body|ConvertTo-Json -Depth 12 -Compress}
|
|
try{$response=Invoke-RestMethod @arguments}catch{throw "Bell request failed for $Method $Path`: $($_.Exception.Message)"}
|
|
if([int]$response.code-ne$ExpectedCode){throw "Unexpected Bell code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"}
|
|
return $response
|
|
}
|
|
function Login([string]$Username,[string]$Password){$response=Invoke-BellJson POST '/api/v1/login' @{username=$Username;password=$Password;code='0';uuid='0'};if([string]::IsNullOrWhiteSpace($response.token)){throw "Login did not return a token for $Username"};return [string]$response.token}
|
|
function Get-VisibleMenuTitles($Menus,[bool]$AncestorsVisible=$true) {
|
|
foreach($menu in @($Menus)) {
|
|
if($null-eq$menu){continue}
|
|
$visible=$AncestorsVisible-and([string]$menu.visible-eq'0')
|
|
if($visible-and-not[string]::IsNullOrWhiteSpace([string]$menu.title)){[string]$menu.title}
|
|
if($menu.PSObject.Properties.Name-contains'children'){
|
|
Get-VisibleMenuTitles -Menus $menu.children -AncestorsVisible $visible
|
|
}
|
|
}
|
|
}
|
|
function Start-Package([string]$Root){
|
|
$script:launcher=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',"`"$(Join-Path $Root 'start-bell.bat')`"") -WorkingDirectory $Root -RedirectStandardOutput $runtimeOut -RedirectStandardError $runtimeErr -WindowStyle Hidden -PassThru
|
|
Wait-Health $script:baseUrl
|
|
}
|
|
function Stop-Package([string]$Root){
|
|
& (Join-Path $Root 'stop-bell.bat') | Out-Host
|
|
if($LASTEXITCODE-ne 0){throw 'Bell package stop failed'}
|
|
Wait-Tcp -Port $script:webPort -Open $false -Attempts 40
|
|
Wait-Tcp -Port $script:backendPort -Open $false -Attempts 40
|
|
if($script:launcher-and-not$script:launcher.HasExited){$script:launcher.WaitForExit(5000)|Out-Null}
|
|
$script:launcher=$null
|
|
}
|
|
function Stop-ProcessTree($Process){if($Process-and-not$Process.HasExited){& taskkill.exe /PID $Process.Id /T /F 2>$null|Out-Null}}
|
|
|
|
New-Item -ItemType Directory -Path $temporary | Out-Null
|
|
try {
|
|
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){$path=Join-Path $PostgresBin $name;if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Required PostgreSQL tool not found: $path"}}
|
|
if([string]::IsNullOrWhiteSpace($PreparedPackageRoot)){
|
|
& (Join-Path $bellRoot 'scripts\build\build-windows.ps1')
|
|
if($LASTEXITCODE-ne 0){throw 'Bell Windows package build failed'}
|
|
$preparedPackageRoot=Join-Path $bellRoot 'dist\bell-windows-amd64'
|
|
}else{$preparedPackageRoot=[IO.Path]::GetFullPath($PreparedPackageRoot)}
|
|
& (Join-Path $bellRoot 'scripts\build\test-package.ps1') -PackageRoot $preparedPackageRoot
|
|
& (Join-Path $bellRoot 'tests\compatibility\assert-go-admin-shell.ps1') -PackageRoot $preparedPackageRoot
|
|
$packageRoot=Join-Path $temporary 'package'
|
|
Copy-Item -LiteralPath $preparedPackageRoot -Destination $packageRoot -Recurse
|
|
# Production captcha behavior is covered by #138. The isolated business
|
|
# E2E uses a disposable package copy in dev mode so it never needs to
|
|
# expose or OCR a captcha answer.
|
|
$settingsPath=Join-Path $packageRoot 'config\settings.yml'
|
|
$settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8
|
|
$testSettings=[regex]::Replace($settings,'(?m)^(\s*mode:\s*)prod\s*$','$1dev')
|
|
if($testSettings-eq$settings){throw 'Packaged settings did not contain the expected production mode'}
|
|
[IO.File]::WriteAllText($settingsPath,$testSettings,(New-Object Text.UTF8Encoding($false)))
|
|
|
|
$pgPort,$script:backendPort,$script:webPort=Get-UniqueFreePorts 3
|
|
if($pgPort-eq 5432){throw 'E2E must not use the default PostgreSQL port'}
|
|
$script:baseUrl="http://127.0.0.1:$script:webPort"
|
|
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U bell_e2e -A trust --encoding=UTF8 --no-locale|Out-Null
|
|
if($LASTEXITCODE-ne 0){throw 'isolated PostgreSQL initdb failed'}
|
|
$pgArguments="-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
|
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput (Join-Path $temporary 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden|Out-Null
|
|
Wait-Tcp -Port $pgPort -Open $true;$pgStarted=$true
|
|
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U bell_e2e bell_e2e
|
|
if($LASTEXITCODE-ne 0){throw 'isolated Bell database creation failed'}
|
|
|
|
$adminName='bell_e2e_admin_'+(New-RandomText 5).ToLowerInvariant();$adminPassword=New-RandomText 20
|
|
$operatorPassword=New-RandomText 20;$jwt=New-RandomText 48
|
|
$environment=@{
|
|
BELL_HOST='127.0.0.1';BELL_PORT="$script:backendPort";BELL_WEB_HOST='127.0.0.1';BELL_WEB_PORT="$script:webPort";
|
|
BELL_DATABASE_URL="host=127.0.0.1 port=$pgPort user=bell_e2e dbname=bell_e2e sslmode=disable";
|
|
BELL_JWT_SECRET=$jwt;BELL_BOOTSTRAP_USERNAME=$adminName;BELL_BOOTSTRAP_PASSWORD=$adminPassword;
|
|
BELL_AUTO_MIGRATE='true';BELL_SYNTHETIC_EVENTS_ENABLED='true'
|
|
}
|
|
foreach($item in $environment.GetEnumerator()){Set-TestEnvironment $item.Key $item.Value}
|
|
Start-Package $packageRoot
|
|
$anonymous=Invoke-BellJson GET '/api/v1/bell/alerts' $null '' 401
|
|
$adminToken=Login $adminName $adminPassword
|
|
$psql=Join-Path $PostgresBin 'psql.exe'
|
|
$operatorRole=[int]((&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select role_id from sys_role where role_key='operator';").Trim())
|
|
if($operatorRole-lt 1){throw 'operator role was not migrated'}
|
|
$operators=@(
|
|
@{username='bell_e2e_operator_a';nickName='处置员A'},
|
|
@{username='bell_e2e_operator_b';nickName='处置员B'}
|
|
)
|
|
foreach($operator in $operators){[void](Invoke-BellJson POST '/api/v1/sys-user' @{username=$operator.username;password=$operatorPassword;nickName=$operator.nickName;phone='13800000000';roleId=$operatorRole;sex='1';email="$($operator.username)@invalid.local";deptId=1;postId=1;status='2'} $adminToken)}
|
|
$tokenA=Login $operators[0].username $operatorPassword;$tokenB=Login $operators[1].username $operatorPassword
|
|
$adminMenu=Invoke-BellJson GET '/api/v1/menurole' $null $adminToken
|
|
$operatorMenu=Invoke-BellJson GET '/api/v1/menurole' $null $tokenA
|
|
$adminVisible=@(Get-VisibleMenuTitles $adminMenu.data)
|
|
$operatorVisible=@(Get-VisibleMenuTitles $operatorMenu.data)
|
|
foreach($label in @('预警管理','事件查询','规则配置')){if($adminVisible-notcontains$label){throw "administrator menu is missing $label; visible=$($adminVisible-join',')"}}
|
|
foreach($label in @('预警管理','事件查询')){if($operatorVisible-notcontains$label){throw "operator menu is missing $label; visible=$($operatorVisible-join',')"}}
|
|
foreach($label in @('开发工具','定时任务','系统监控')){if($adminVisible-contains$label-or$operatorVisible-contains$label){throw "unrelated menu is visible: $label"}}
|
|
|
|
$eventType='bell_e2e_danger';$ruleBody=@{code='bell-e2e-danger';name='E2E危险区域规则';eventType=$eventType;minimumSeverity='medium';locationContains='东门'}
|
|
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $tokenA 403)
|
|
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $adminToken)
|
|
$eventBody=Get-Content -LiteralPath (Join-Path $bellRoot 'tests\fixtures\synthetic-danger-event.json') -Raw -Encoding UTF8|ConvertFrom-Json
|
|
$eventBody.eventType=$eventType
|
|
$created=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
|
|
$replay=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
|
|
if($created.data.duplicate-ne$false-or$replay.data.duplicate-ne$true-or$created.data.event.id-ne$replay.data.event.id){throw 'synthetic Event idempotency failed'}
|
|
$eventId=[string]$created.data.event.id
|
|
$alerts=Invoke-BellJson GET '/api/v1/bell/alerts?status=open&pageIndex=1&pageSize=20' $null $tokenA
|
|
$alert=@($alerts.data.list)[0]
|
|
if(-not$alert){throw 'rule evaluation did not create an open Alert'}
|
|
$alertId=[string]$alert.id
|
|
$alertDetail=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId" $null $tokenA).data
|
|
if(@($alertDetail.events.id)-notcontains$eventId){throw 'created Alert is not linked to the synthetic Event'}
|
|
|
|
$requests=for($i=0;$i-lt 20;$i++){[pscustomobject]@{Token=$(if($i%2-eq0){$tokenA}else{$tokenB})}}
|
|
$acks=$requests|ForEach-Object -Parallel {
|
|
$headers=@{Authorization="Bearer $($_.Token)"}
|
|
$response=Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/alerts/$using:alertId/ack" -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20 -NoProxy
|
|
[pscustomobject]@{Token=$_.Token;Response=$response}
|
|
} -ThrottleLimit 20
|
|
$winners=@($acks|Where-Object{$_.Response.data.won-eq$true})
|
|
if($winners.Count-ne 1){throw "concurrent ack winners=$($winners.Count)"}
|
|
$lifecycle=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $tokenA).data.detail
|
|
if($lifecycle.timeline.Count-ne 1-or$lifecycle.projection.status-ne'acknowledged'){throw 'ack lifecycle projection is inconsistent'}
|
|
$winnerToken=[string]$winners[0].Token
|
|
$loserToken=$(if($winnerToken-eq$tokenA){$tokenB}else{$tokenA})
|
|
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal'} $loserToken 403)
|
|
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{} $winnerToken 400)
|
|
$closed=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
|
|
$closeReplay=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
|
|
if($closed.data.won-ne$true-or$closeReplay.data.idempotent-ne$true){throw 'close or idempotent replay failed'}
|
|
$final=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
|
|
if($final.timeline.Count-ne 2-or$final.projection.status-ne'closed'){throw 'closed timeline is incomplete'}
|
|
$facts=(&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select (select count(*) from bell_events),(select count(*) from bell_event_receipts),(select count(*) from bell_alert_lifecycle_facts where alert_id='$alertId');").Trim()
|
|
if($facts-ne'1|1|2'){throw "unexpected persisted fact counts: $facts"}
|
|
|
|
Stop-Package $packageRoot
|
|
Start-Package $packageRoot
|
|
$after=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
|
|
if($after.timeline.Count-ne 2-or$after.projection.closeOutcome-ne'site_normal'){throw 'cold restart lost lifecycle state'}
|
|
$rootPage=Invoke-WebRequest -Uri "$script:baseUrl/" -TimeoutSec 10 -NoProxy
|
|
if($rootPage.StatusCode-ne 200-or$rootPage.Content-notmatch'id=["'']app["'']'){throw 'packaged GoAdmin web shell is not available'}
|
|
if($BrowserHold){
|
|
$browserSession=Join-Path $temporary 'browser-session.json';$browserDone=Join-Path $temporary 'browser-done'
|
|
@{baseUrl=$script:baseUrl;username=$adminName;password=$adminPassword;alertId=$alertId}|ConvertTo-Json|Set-Content -LiteralPath $browserSession -Encoding UTF8
|
|
Write-Host "Bell browser session ready: $browserSession"
|
|
for($i=0;$i-lt 1200-and-not(Test-Path -LiteralPath $browserDone);$i++){Start-Sleep -Milliseconds 500}
|
|
if(-not(Test-Path -LiteralPath $browserDone)){throw 'Browser verification did not signal completion within 10 minutes'}
|
|
}
|
|
Stop-Package $packageRoot
|
|
foreach($log in @($runtimeOut,$runtimeErr,(Join-Path $packageRoot 'runtime\logs\bell.out.log'),(Join-Path $packageRoot 'runtime\logs\bell.err.log'))){if(Test-Path $log){$text=[string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue);foreach($secret in @($adminPassword,$operatorPassword,$jwt,$adminToken,$tokenA,$tokenB)){if($text.Contains($secret)){throw "runtime log exposed an E2E credential: $log"}}}}
|
|
Write-Host "Bell isolated E2E passed: health/login/RBAC, minimal menu, Event/Receipt idempotency, Rule/Alert, 20 concurrent ack, close authorization/idempotency, timeline, cold restart, package start/stop. base_url=$script:baseUrl"
|
|
} finally {
|
|
try { if($launcher){Stop-Package $packageRoot} } catch { Stop-ProcessTree $launcher }
|
|
if($pgStarted){Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$pgData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporary 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden|Out-Null;try{Wait-Tcp -Port $pgPort -Open $false -Attempts 40}catch{}}
|
|
foreach($item in $savedEnvironment.GetEnumerator()){[Environment]::SetEnvironmentVariable($item.Key,$item.Value,'Process')}
|
|
if(-not$KeepTemporary-and(Test-Path -LiteralPath $temporary)){$resolved=[IO.Path]::GetFullPath($temporary);if(-not$resolved.StartsWith([IO.Path]::GetTempPath(),[StringComparison]::OrdinalIgnoreCase)){throw "Unsafe temporary cleanup path: $resolved"};Remove-Item -LiteralPath $resolved -Recurse -Force}elseif($KeepTemporary){Write-Host "Kept Bell E2E directory: $temporary"}
|
|
}
|