diff --git a/Bell/ACCEPTANCE.md b/Bell/ACCEPTANCE.md new file mode 100644 index 0000000..1fd6f85 --- /dev/null +++ b/Bell/ACCEPTANCE.md @@ -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 单测替代;未执行的项目必须在工单证据中明确说明。 diff --git a/Bell/README-WINDOWS.md b/Bell/README-WINDOWS.md new file mode 100644 index 0000000..2401649 --- /dev/null +++ b/Bell/README-WINDOWS.md @@ -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 或生命周期事实。 diff --git a/Bell/README.md b/Bell/README.md index 37b1d5b..f784d6f 100644 --- a/Bell/README.md +++ b/Bell/README.md @@ -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。 diff --git a/Bell/config/bell.env.example b/Bell/config/bell.env.example new file mode 100644 index 0000000..7d6c385 --- /dev/null +++ b/Bell/config/bell.env.example @@ -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 diff --git a/Bell/scripts/build/assert-web-assets.ps1 b/Bell/scripts/build/assert-web-assets.ps1 new file mode 100644 index 0000000..c86ff54 --- /dev/null +++ b/Bell/scripts/build/assert-web-assets.ps1 @@ -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)=["''](?/[^"''?#]+)') | 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" diff --git a/Bell/scripts/build/build-windows.bat b/Bell/scripts/build/build-windows.bat new file mode 100644 index 0000000..19d1848 --- /dev/null +++ b/Bell/scripts/build/build-windows.bat @@ -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% diff --git a/Bell/scripts/build/build-windows.ps1 b/Bell/scripts/build/build-windows.ps1 new file mode 100644 index 0000000..6984be7 --- /dev/null +++ b/Bell/scripts/build/build-windows.ps1 @@ -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, ']+type="module"[^>]*>', '') + $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 } +} diff --git a/Bell/scripts/build/test-package.ps1 b/Bell/scripts/build/test-package.ps1 new file mode 100644 index 0000000..e839feb --- /dev/null +++ b/Bell/scripts/build/test-package.ps1 @@ -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" diff --git a/Bell/scripts/runtime/bell-common.ps1 b/Bell/scripts/runtime/bell-common.ps1 new file mode 100644 index 0000000..9dfad34 --- /dev/null +++ b/Bell/scripts/runtime/bell-common.ps1 @@ -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)(?[A-Za-z_][A-Za-z0-9_]*)=(?''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\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" +} diff --git a/Bell/scripts/runtime/bell-web.ps1 b/Bell/scripts/runtime/bell-web.ps1 new file mode 100644 index 0000000..394c75e --- /dev/null +++ b/Bell/scripts/runtime/bell-web.ps1 @@ -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() +} diff --git a/Bell/scripts/runtime/check-bell.bat b/Bell/scripts/runtime/check-bell.bat new file mode 100644 index 0000000..3205516 --- /dev/null +++ b/Bell/scripts/runtime/check-bell.bat @@ -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% diff --git a/Bell/scripts/runtime/check-bell.ps1 b/Bell/scripts/runtime/check-bell.ps1 new file mode 100644 index 0000000..3632534 --- /dev/null +++ b/Bell/scripts/runtime/check-bell.ps1 @@ -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 } diff --git a/Bell/scripts/runtime/start-bell.bat b/Bell/scripts/runtime/start-bell.bat new file mode 100644 index 0000000..131d044 --- /dev/null +++ b/Bell/scripts/runtime/start-bell.bat @@ -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% diff --git a/Bell/scripts/runtime/start-bell.ps1 b/Bell/scripts/runtime/start-bell.ps1 new file mode 100644 index 0000000..18735fa --- /dev/null +++ b/Bell/scripts/runtime/start-bell.ps1 @@ -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 } +} diff --git a/Bell/scripts/runtime/stop-bell.bat b/Bell/scripts/runtime/stop-bell.bat new file mode 100644 index 0000000..054aa7e --- /dev/null +++ b/Bell/scripts/runtime/stop-bell.bat @@ -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% diff --git a/Bell/scripts/runtime/stop-bell.ps1 b/Bell/scripts/runtime/stop-bell.ps1 new file mode 100644 index 0000000..62eeb58 --- /dev/null +++ b/Bell/scripts/runtime/stop-bell.ps1 @@ -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 } diff --git a/Bell/scripts/test-independent-e2e.ps1 b/Bell/scripts/test-independent-e2e.ps1 new file mode 100644 index 0000000..deca1e4 --- /dev/null +++ b/Bell/scripts/test-independent-e2e.ps1 @@ -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 diff --git a/Bell/tests/compatibility/assert-go-admin-shell.ps1 b/Bell/tests/compatibility/assert-go-admin-shell.ps1 new file mode 100644 index 0000000..03ad302 --- /dev/null +++ b/Bell/tests/compatibility/assert-go-admin-shell.ps1 @@ -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.' diff --git a/Bell/tests/e2e/run-isolated-e2e.ps1 b/Bell/tests/e2e/run-isolated-e2e.ps1 new file mode 100644 index 0000000..86b50ab --- /dev/null +++ b/Bell/tests/e2e/run-isolated-e2e.ps1 @@ -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"} +} diff --git a/Bell/tests/fixtures/synthetic-danger-event.json b/Bell/tests/fixtures/synthetic-danger-event.json new file mode 100644 index 0000000..485bda1 --- /dev/null +++ b/Bell/tests/fixtures/synthetic-danger-event.json @@ -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 + } +}