test: Bell 独立纵切与 Windows 交付验证 (#134) #144

Merged
ila merged 1 commits from feature/134-bell-independent-e2e into dev 2026-08-29 17:34:07 +08:00
20 changed files with 783 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# Bell 独立纵切验收
本验收只使用 Bell 自身、临时 PostgreSQL 和项目内合成事件,不启动或调用 Sense、Brain,不连接默认 5432、生产数据库或客户数据。
## 固定工具链
```powershell
$env:GOTOOLCHAIN='go1.26.5'
go version
node --version
corepack pnpm@9.15.1 --version
```
预期分别为 Go 1.26.5、Node 22.22.1、pnpm 9.15.1。
## 源码验证
```powershell
Set-Location Bell\server
$env:GOTOOLCHAIN='go1.26.5'
go test ./... -count=1
go vet ./...
go build ./...
Set-Location ..\ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
corepack pnpm@9.15.1 test:unit --runInBand
corepack pnpm@9.15.1 build:prod
```
## Windows 包和隔离 E2E
```powershell
Set-Location <仓库根目录>
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64
```
E2E 自动完成并清理:临时 PostgreSQL、随机数据库/HTTP 端口、随机管理员/处置员凭据、迁移、健康检查、登录/RBAC、最小 Bell 菜单、规则、合成 Event/Receipt 幂等、Alert、20 路并发 ack、越权/缺参拒绝、close 重放幂等、两条生命周期时间线、冷重启、Windows stop 和日志泄密检查。原始包保持生产配置并先通过审计;业务自动化只把临时包副本切换为 `dev` 测试模式。生产验证码的获取、正确登录、错误及重放拒绝由 #138 的 `Bell/server/tests/bell_production_login/run-postgres.ps1` 覆盖,不暴露或识别验证码答案。
浏览器验收打开脚本输出的临时 `base_url`,检查:
- 匿名访问跳转登录页,并显示验证码输入;测试模式可填写任意非空验证码,生产验证码行为由 #138 回归覆盖;
- 登录后保留 GoAdmin 侧栏、顶部导航和标签页;
- 管理员显示 Bell 必要业务菜单,包括预警管理、事件查询、规则配置;处置员仅显示预警处理所需入口;
- 预警详情可显示关联事件、处理人、现场结果和两条处理时间线;
- 不显示开发工具、定时任务、系统监控等无关入口。
## 仓库闭环
```powershell
python dev_scripts/harness.py check --strict
git diff --check
git status --short --branch
```
浏览器人工/工具检查、真实生产数据库、客户网络和长期负载不由 API 单测替代;未执行的项目必须在工单证据中明确说明。
+59
View File
@@ -0,0 +1,59 @@
# Bell Windows 运行说明
Bell Windows 包包含独立后端、GoAdmin 管理端静态资源和启动、停止、检查脚本。正式运行需要独立 PostgreSQL;包内不提供默认账号、密码、JWT secret 或数据库。
## 配置
编辑 `config\bell.env`:
```text
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=host=127.0.0.1 port=5432 user=bell dbname=bell sslmode=disable
BELL_JWT_SECRET=<至少 32 字符的独立随机值>
BELL_BOOTSTRAP_USERNAME=<仅首次迁移使用>
BELL_BOOTSTRAP_PASSWORD=<仅首次迁移使用,至少 8 字符>
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
```
不要把真实配置提交到 Git。首次迁移成功后,建议从进程环境中移除 `BELL_BOOTSTRAP_PASSWORD`;它不会写入明文数据库。
## 启动、检查和停止
```bat
check-bell.bat
start-bell.bat
check-bell.bat -Running
stop-bell.bat
```
浏览器访问 `http://127.0.0.1:18091/`。`BELL_PORT` 是仅供本机 Web 网关访问的后端端口;`BELL_WEB_PORT` 是用户访问入口。启动脚本默认先执行幂等数据库迁移,再启动后端和 Web 网关;任一步失败都会返回非零退出码。
`stop-bell.bat` 只按包内 PID 文件和启动命令行核对后停止本包进程树,不按端口终止未知进程。运行日志位于 `runtime\logs`,不得包含密码、JWT 或登录 token。
## 构建和包审计
从仓库根目录运行:
```powershell
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
```
输出:
- `Bell\dist\bell-windows-amd64\`
- `Bell\dist\bell-windows-amd64.zip`
包内 `VERSION.txt`、`MANIFEST.sha256` 和 `LICENSES\` 分别记录源码提交、工具链、文件摘要、GoAdmin 来源及 MIT 许可证。
## 常见错误
- `BELL_DATABASE_URL is required`:设置独立 PostgreSQL 连接串。
- `PostgreSQL is unreachable`:启动 PostgreSQL,并检查地址和端口。
- `BELL_JWT_SECRET must contain...`:生成至少 32 字符、只供 Bell 使用的随机值。
- `port ... is already in use`:停止已有 Bell,或修改后端/Web 端口。
- `Bell database migration failed`:检查数据库是否存在、用户权限及迁移日志;不要删除已有 Event、Alert 或生命周期事实。
+9
View File
@@ -37,3 +37,12 @@ corepack pnpm@9.15.1 dev
```
生产构建使用 `corepack pnpm@9.15.1 build:prod`。生产环境不会生成或接受仓库默认管理员、默认 JWT secret 或默认数据库连接串。
## Windows 交付与独立验收
- Windows 构建:`Bell\scripts\build\build-windows.bat`
- 包审计:`pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64`
- 隔离 E2E:`pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64`
- 包内启动、检查和停止:`start-bell.bat`、`check-bell.bat -Running`、`stop-bell.bat`
完整配置、排错和验收标准见 `README-WINDOWS.md` 与 `ACCEPTANCE.md`。隔离 E2E 使用临时 PostgreSQL、随机端口和随机凭据,不启动或调用 Sense、Brain。
+11
View File
@@ -0,0 +1,11 @@
# Bell production environment. Copy values into process environment or this file.
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=
BELL_JWT_SECRET=
BELL_BOOTSTRAP_USERNAME=
BELL_BOOTSTRAP_PASSWORD=
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
+13
View File
@@ -0,0 +1,13 @@
param([Parameter(Mandatory = $true)][string]$WebRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot)
$index = Join-Path $root 'index.html'
if (-not (Test-Path -LiteralPath $index -PathType Leaf)) { throw 'web/index.html is missing.' }
$html = Get-Content -LiteralPath $index -Raw -Encoding UTF8
$references = [regex]::Matches($html, '(?:src|href)=["''](?<path>/[^"''?#]+)') | ForEach-Object { $_.Groups['path'].Value.TrimStart('/').Replace('/', '\') }
foreach ($relative in $references | Sort-Object -Unique) {
if ($relative -match '^https?:') { continue }
if (-not (Test-Path -LiteralPath (Join-Path $root $relative) -PathType Leaf)) { throw "web asset referenced by index.html is missing: $relative" }
}
Write-Host "Bell web asset check passed: $root"
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0build-windows.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0build-windows.ps1" %*)
exit /b %errorlevel%
+88
View File
@@ -0,0 +1,88 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$bellRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$repositoryRoot = Split-Path $bellRoot -Parent
$serverRoot = Join-Path $bellRoot 'server'
$uiRoot = Join-Path $bellRoot 'ui'
$distRoot = Join-Path $bellRoot 'dist'
$target = Join-Path $distRoot 'bell-windows-amd64'
$archive = Join-Path $distRoot 'bell-windows-amd64.zip'
$staging = Join-Path $distRoot ('.bell-windows-amd64.staging-' + $PID)
function Assert-ChildPath([string]$Parent,[string]$Child) {
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
$childPath = [IO.Path]::GetFullPath($Child)
if (-not $childPath.StartsWith($parentPath,[StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe build path outside $Parent`: $Child" }
}
function Get-FileSha256([string]$Path) {
$sha = [Security.Cryptography.SHA256]::Create(); $stream = [IO.File]::OpenRead($Path)
try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','') } finally { $stream.Dispose(); $sha.Dispose() }
}
Assert-ChildPath $bellRoot $distRoot; Assert-ChildPath $distRoot $target; Assert-ChildPath $distRoot $archive; Assert-ChildPath $distRoot $staging
$savedToolchain = $env:GOTOOLCHAIN
$env:GOTOOLCHAIN = 'go1.26.5'
try {
Push-Location $serverRoot
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
$nodeVersion = (& node --version).Trim().TrimStart('v')
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; found $goVersion." }
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; found $pnpmVersion." }
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
New-Item -ItemType Directory -Path $staging | Out-Null
Push-Location $uiRoot
try {
& corepack pnpm@9.15.1 install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
& corepack pnpm@9.15.1 run build:prod
if ($LASTEXITCODE -ne 0) { throw 'Bell UI production build failed.' }
# The frozen Vue CLI differential build references a module runtime
# that ScriptExt removes from disk. The complete legacy bundle is
# present, so make that reproducible bundle the package entry point.
$builtIndex = Join-Path $uiRoot 'dist\index.html'
$html = Get-Content -LiteralPath $builtIndex -Raw -Encoding UTF8
$html = [regex]::Replace($html, '<script[^>]+type="module"[^>]*></script>', '')
$html = $html.Replace(' nomodule', '')
[IO.File]::WriteAllText($builtIndex, $html, (New-Object Text.UTF8Encoding($false)))
} finally { Pop-Location }
$oldGOOS,$oldGOARCH,$oldCGO = $env:GOOS,$env:GOARCH,$env:CGO_ENABLED
try {
$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
Push-Location $serverRoot
try { & go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'bell.exe') .; if ($LASTEXITCODE -ne 0) { throw 'Bell server Windows build failed.' } } finally { Pop-Location }
} finally { $env:GOOS,$env:GOARCH,$env:CGO_ENABLED=$oldGOOS,$oldGOARCH,$oldCGO }
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'),(Join-Path $staging 'config'),(Join-Path $staging 'LICENSES') | Out-Null
Copy-Item -Path (Join-Path $bellRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
foreach ($name in @('start-bell','stop-bell','check-bell')) { Copy-Item -LiteralPath (Join-Path $bellRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") }
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env.example')
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\settings.yml') -Destination (Join-Path $staging 'config\settings.yml')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
Copy-Item -LiteralPath (Join-Path $bellRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
Copy-Item -LiteralPath (Join-Path $bellRoot 'LICENSES') -Destination $staging -Recurse -Force
Copy-Item -LiteralPath (Join-Path $serverRoot 'LICENSE.md') -Destination (Join-Path $staging 'LICENSES\Bell-server-LICENSE.md')
Copy-Item -LiteralPath (Join-Path $uiRoot 'LICENSE') -Destination (Join-Path $staging 'LICENSES\Bell-ui-LICENSE')
$commit = (& git -C $repositoryRoot rev-parse HEAD).Trim()
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'),@("source_commit=$commit",'go=1.26.5','node=22.22.1','pnpm=9.15.1'),(New-Object Text.UTF8Encoding($false)))
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
if ($LASTEXITCODE -ne 0) { throw 'Bell package audit failed.' }
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { "$(Get-FileSha256 $file.FullName) $($file.FullName.Substring($staging.Length+1).Replace('\','/'))" }
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'),$manifest,(New-Object Text.UTF8Encoding($false)))
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
Move-Item -LiteralPath $staging -Destination $target
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
Write-Host "Bell Windows package: $target"
Write-Host "Bell Windows archive: $archive"
} finally {
$env:GOTOOLCHAIN = $savedToolchain
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
}
+34
View File
@@ -0,0 +1,34 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($PackageRoot)
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
$required = @(
'bell.exe','start-bell.bat','stop-bell.bat','check-bell.bat','README-WINDOWS.md',
'config\bell.env','config\bell.env.example','config\settings.yml','config\db.sql','config\pg.sql','web\index.html',
'scripts\runtime\bell-common.ps1','scripts\runtime\bell-web.ps1',
'LICENSES\SOURCES.md','LICENSES\go-admin-LICENSE.md','LICENSES\go-admin-ui-LICENSE',
'VERSION.txt'
)
foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } }
& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web')
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules','.git','dist','.cache') }
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db','.sqlite','.sqlite3','.dump','.bak') }
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
$config = Get-Content -LiteralPath (Join-Path $root 'config\bell.env') -Raw -Encoding UTF8
foreach ($secret in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD')) {
if ($config -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty credential field: $secret" }
}
$sources = Get-Content -LiteralPath (Join-Path $root 'LICENSES\SOURCES.md') -Raw -Encoding UTF8
foreach ($commit in @('f06540883b41d03782bb6b2c4150f298f328c6b6','67d393d713877572fab0b897296a4c1d525fc81d','424855aacf6905f3fde860c3331385cb25529a0d')) {
if (-not $sources.Contains($commit)) { throw "Package source evidence is missing commit $commit" }
}
$version = Get-Content -LiteralPath (Join-Path $root 'VERSION.txt') -Raw -Encoding UTF8
foreach ($entry in @('go=1.26.5','node=22.22.1','pnpm=9.15.1')) { if (-not $version.Contains($entry)) { throw "Package version evidence is missing $entry" } }
$textExtensions = @('.md','.txt','.env','.example','.ps1','.bat','.yml','.yaml','.json','.html','.js','.css')
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
$content = [string](Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue)
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') { throw "Package contains a forbidden default credential or private key marker: $($file.FullName)" }
}
Write-Host "Bell package audit passed: $root"
+117
View File
@@ -0,0 +1,117 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$script:BellAllowedEnvironment = @(
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
'BELL_SYNTHETIC_EVENTS_ENABLED'
)
function Get-BellPackageRoot {
param([string]$ScriptDirectory = $PSScriptRoot)
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
}
function Import-BellEnvironment {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" }
$lineNumber = 0
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
$lineNumber++
$line = $rawLine.Trim()
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
$separator = $line.IndexOf('=')
if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." }
$name = $line.Substring(0, $separator).Trim()
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" }
$value = $line.Substring($separator + 1)
if ($value.Length -ge 2) {
$first, $last = $value[0], $value[$value.Length - 1]
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
}
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Get-BellEnvironmentValue {
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function Test-BellTcpEndpoint {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
$client = [Net.Sockets.TcpClient]::new()
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
}
function Test-BellListenPortAvailable {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
}
function Get-BellDatabaseEndpoint {
param([Parameter(Mandatory = $true)][string]$Connection)
if ($Connection -match '^postgres(?:ql)?://') {
$uri = [Uri]$Connection
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
}
$values = @{}
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
$value = $match.Groups['value'].Value.Trim("'", '"')
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
}
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
}
function Get-BellPort {
param([string]$Name, [int]$Default)
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
$port = 0
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
return $port
}
function Initialize-BellRuntime {
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
if (-not $AllowOccupiedPorts) {
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
}
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
$webRoot = Join-Path $PackageRoot 'web'
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
return [pscustomobject]@{
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
}
}
function Wait-BellHealth {
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
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 check timed out: $BaseUrl/healthz"
}
+72
View File
@@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)][string]$WebRoot,
[Parameter(Mandatory = $true)][string]$ListenHost,
[Parameter(Mandatory = $true)][int]$ListenPort,
[Parameter(Mandatory = $true)][string]$BackendUrl
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
$handler = [Net.Http.HttpClientHandler]::new()
$handler.UseProxy = $false
$client = [Net.Http.HttpClient]::new($handler)
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
try {
$listener.Start()
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
while ($listener.IsListening) {
$context = $listener.GetContext()
try {
$request = $context.Request
$response = $context.Response
$path = $request.Url.AbsolutePath
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
$target = "$BackendUrl$($request.Url.PathAndQuery)"
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
if ($request.HasEntityBody) {
$memory = [IO.MemoryStream]::new()
$request.InputStream.CopyTo($memory)
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
$memory.Dispose()
}
foreach ($key in $request.Headers.AllKeys) {
if ($key -in @('Host','Content-Length')) { continue }
$values = $request.Headers.GetValues($key)
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
}
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$response.StatusCode = [int]$upstream.StatusCode
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$message.Dispose(); $upstream.Dispose()
} else {
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
$file = Join-Path $root 'index.html'
}
if ($response.StatusCode -ne 403) {
$bytes = [IO.File]::ReadAllBytes($file)
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
}
}
} catch {
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
} finally {
try { $context.Response.OutputStream.Close() } catch {}
}
}
} finally {
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
exit /b %errorlevel%
+12
View File
@@ -0,0 +1,12 @@
param([switch]$Running)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
if ($Running) {
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
}
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
exit /b %errorlevel%
+41
View File
@@ -0,0 +1,41 @@
param([switch]$SkipMigration)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
$backend = $null
$pidFile = $null
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root
$bell = Join-Path $root 'bell.exe'
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
$runtime = Join-Path $root 'runtime'
$logs = Join-Path $runtime 'logs'
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
$pidFile = Join-Path $runtime 'bell.pid'
if (Test-Path -LiteralPath $pidFile) {
$oldPid = 0
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
Remove-Item -LiteralPath $pidFile -Force
}
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
Push-Location $root
try {
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
Write-Host 'Applying pending Bell database migrations...'
& $bell migrate -c $state.SettingsPath
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
}
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
Wait-BellHealth -BaseUrl $state.BackendUrl
Write-Host "Bell is available at $($state.WebUrl)/"
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
} finally { Pop-Location }
} catch {
Write-Error $_.Exception.Message
exit 1
} finally {
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
exit /b %errorlevel%
+17
View File
@@ -0,0 +1,17 @@
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$pidFile = Join-Path $root 'runtime\bell.pid'
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
$processId = 0
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
$rootPattern = [regex]::Escape($root)
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
& taskkill.exe /PID $processId /T /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
Write-Host 'Bell backend and web process tree stopped.'
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+7
View File
@@ -0,0 +1,7 @@
param([string]$PostgresBin='D:\pgsql17\bin',[string]$PreparedPackageRoot='',[switch]$KeepTemporary,[switch]$BrowserHold)
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot '..\tests\e2e\run-isolated-e2e.ps1'),'-PostgresBin',$PostgresBin)
if(-not[string]::IsNullOrWhiteSpace($PreparedPackageRoot)){$arguments+=@('-PreparedPackageRoot',$PreparedPackageRoot)}
if($KeepTemporary){$arguments+='-KeepTemporary'}
if($BrowserHold){$arguments+='-BrowserHold'}
& pwsh.exe @arguments
exit $LASTEXITCODE
@@ -0,0 +1,8 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$web = Join-Path ([IO.Path]::GetFullPath($PackageRoot)) 'web'
& (Join-Path $PSScriptRoot '..\..\scripts\build\assert-web-assets.ps1') -WebRoot $web
$index = Get-Content -LiteralPath (Join-Path $web 'index.html') -Raw -Encoding UTF8
if ($index -notmatch 'id=["'']app["'']') { throw 'Bell package does not contain the GoAdmin Vue application mount.' }
Write-Host 'Bell GoAdmin shell compatibility check passed.'
+204
View File
@@ -0,0 +1,204 @@
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"}
}
+12
View File
@@ -0,0 +1,12 @@
{
"sourceEventId": "bell-e2e-danger-001",
"eventType": "danger_area_entered",
"occurredAt": "2026-08-29T00:00:00Z",
"location": "东门危险区域",
"severity": "high",
"evidenceRef": "e2e/evidence/bell-e2e-danger-001",
"attributes": {
"target": "anonymous",
"fixture": true
}
}