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"} }