Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5128f080b4 | ||
|
|
e81f00e9aa | ||
|
|
5adee5c3b4 | ||
|
|
a69ef627c7 | ||
|
|
d4de462d44 | ||
|
|
0276bceab5 | ||
|
|
c2b2943a3a | ||
|
|
55b12df373 | ||
|
|
27d465c250 | ||
|
|
4a2c4aa638 | ||
|
|
504dd1a2e9 | ||
|
|
e2f7183ecf | ||
|
|
eb4e1a9ea1 | ||
|
|
9055f2522c | ||
|
|
130087a1ba | ||
|
|
0e53e04e95 | ||
|
|
04c5deecfb | ||
|
|
82afce1f81 | ||
|
|
cf00d73436 | ||
|
|
ae9bd015c2 | ||
|
|
06e0790f00 | ||
|
|
009dc3cca0 | ||
|
|
573113eb3b | ||
|
|
b548b05874 | ||
|
|
23a85278cb | ||
|
|
96777a948f | ||
|
|
c2b023c9fe | ||
|
|
4c35da9ef6 | ||
|
|
30c43aa8d7 | ||
|
|
a22d3ce0f1 | ||
|
|
359c553452 | ||
|
|
2e61167500 | ||
|
|
54c58551ae | ||
|
|
67391acb16 | ||
|
|
2a395aa126 | ||
|
|
e4fed702c4 | ||
|
|
49aa79f3b9 | ||
|
|
64e20e6aed | ||
|
|
19c0868c5d | ||
|
|
1c6b30fac0 | ||
|
|
6702b8a5b9 | ||
|
|
a35f1d6770 | ||
|
|
6194b664ee | ||
|
|
1caa429cad | ||
|
|
f99fe8d4f7 | ||
|
|
b4a8e0e1f1 | ||
|
|
86c3e79121 | ||
|
|
cabc29c18b | ||
|
|
452cd71035 | ||
|
|
f09a61e5fb | ||
|
|
afc58f7bf4 | ||
|
|
b01ca1fe09 | ||
|
|
689de560bb | ||
|
|
8f7d91310b | ||
|
|
96bd4ad2c8 | ||
|
|
adbd1c6aba | ||
|
|
6ffdbcce84 | ||
|
|
f6f561f2e2 | ||
|
|
ee9cfb0433 | ||
|
|
407ffa17b2 | ||
|
|
ba4ec28763 | ||
|
|
b9b067213f | ||
|
|
52b368068e | ||
|
|
7964d61cab | ||
|
|
4a605f6482 | ||
|
|
d61e6d5ee1 | ||
|
|
7274bd42f5 | ||
|
|
02a5af5e3b | ||
|
|
e06904272a | ||
|
|
d681fd1345 | ||
|
|
bdd78523b5 | ||
|
|
2db49fc955 | ||
|
|
a9fd8e8a47 | ||
|
|
12f5419f21 | ||
|
|
debf662138 | ||
|
|
184f101a86 | ||
|
|
4157d90e9f | ||
|
|
028519a710 | ||
|
|
44b60b5b46 | ||
|
|
ae19e59703 | ||
|
|
2e709aa4ff | ||
|
|
870cb4acec | ||
|
|
9c874734af |
@@ -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 单测替代;未执行的项目必须在工单证据中明确说明。
|
||||
@@ -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 或生命周期事实。
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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%
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,190 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$server = $null
|
||||
$pgStarted = $false
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-131-' + [guid]::NewGuid().ToString('N'))
|
||||
$pgData = Join-Path $testRoot 'postgres'
|
||||
$pgLog = Join-Path $testRoot 'postgres.log'
|
||||
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
|
||||
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
|
||||
$serverOut = Join-Path $testRoot 'bell.out.log'
|
||||
$serverErr = Join-Path $testRoot 'bell.err.log'
|
||||
$serverExe = Join-Path $testRoot 'bell-server.exe'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\server')).Path
|
||||
|
||||
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 Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
$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 ($attempt = 0; $attempt -lt 100; $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 endpoint did not become ready'
|
||||
}
|
||||
|
||||
function Start-Bell([string]$BaseUrl, [string]$Config = 'config/settings.demo.yml') {
|
||||
$script:server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', $Config) -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $BaseUrl
|
||||
}
|
||||
|
||||
function Stop-Bell {
|
||||
if ($null -ne $script:server -and -not $script:server.HasExited) {
|
||||
Stop-Process -Id $script:server.Id -Force
|
||||
$script:server.WaitForExit(5000) | Out-Null
|
||||
}
|
||||
$script:server = $null
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreeTcpPort
|
||||
$bellPort = Get-FreeTcpPort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
$database = 'bell_131'
|
||||
$adminPassword = [guid]::NewGuid().ToString('N')
|
||||
|
||||
try {
|
||||
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
|
||||
$path = Join-Path $PostgresBin $required
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
|
||||
}
|
||||
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -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 $pgCtlOut -RedirectStandardError $pgCtlErr -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 postgres $database
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_131_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = $adminPassword
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = $bellPort.ToString()
|
||||
$env:BELL_SYNTHETIC_EVENTS_ENABLED = 'true'
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell migration failed' }
|
||||
go build -o $serverExe .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Start-Bell $baseUrl
|
||||
$unauthorized = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 5 -NoProxy
|
||||
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated request returned code $($unauthorized.code)" }
|
||||
|
||||
$loginBody = @{ username = 'bell_131_admin'; password = $adminPassword; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$login = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $loginBody -TimeoutSec 5 -NoProxy
|
||||
if ([int]$login.code -ne 200 -or [string]::IsNullOrWhiteSpace($login.token)) { throw 'Bell login failed' }
|
||||
$headers = @{ Authorization = "Bearer $($login.token)" }
|
||||
|
||||
$occurredAt = '2026-08-29T00:00:00Z'
|
||||
$eventBody = @{
|
||||
sourceEventId = 'acceptance-001'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
|
||||
location = '东门'; severity = 'high'; evidenceRef = 'evidence/acceptance-001'
|
||||
attributes = @{ rule = 'area-01'; target = 'anonymous' }
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
|
||||
if ([int]$created.code -ne 200 -or $created.data.duplicate -ne $false) { throw 'first synthetic event was not accepted as new' }
|
||||
$eventId = [string]$created.data.event.id
|
||||
$receiptId = [string]$created.data.receipt.id
|
||||
if ([string]::IsNullOrWhiteSpace($eventId) -or [string]::IsNullOrWhiteSpace($receiptId)) { throw 'event or receipt id missing' }
|
||||
|
||||
$replay = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
|
||||
if ([int]$replay.code -ne 200 -or $replay.data.duplicate -ne $true -or $replay.data.event.id -ne $eventId) { throw 'idempotent replay failed' }
|
||||
|
||||
$concurrentBody = @{
|
||||
sourceEventId = 'acceptance-concurrent'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
|
||||
location = '西门'; severity = 'medium'; evidenceRef = 'evidence/acceptance-concurrent'
|
||||
attributes = @{ rule = 'area-02'; target = 'anonymous' }
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
$authHeader = [string]$headers.Authorization
|
||||
$concurrent = 1..12 | ForEach-Object -Parallel {
|
||||
$requestHeaders = @{ Authorization = $using:authHeader }
|
||||
Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/synthetic-events" -Headers $requestHeaders -ContentType 'application/json; charset=utf-8' -Body $using:concurrentBody -TimeoutSec 20 -NoProxy
|
||||
} -ThrottleLimit 12
|
||||
$concurrentIds = @($concurrent | ForEach-Object { [string]$_.data.event.id } | Sort-Object -Unique)
|
||||
$newCount = @($concurrent | Where-Object { $_.data.duplicate -eq $false }).Count
|
||||
if ($concurrent.Count -ne 12 -or $concurrentIds.Count -ne 1 -or $newCount -ne 1) { throw 'concurrent idempotency did not converge to one Event' }
|
||||
|
||||
$conflictObject = $eventBody | ConvertFrom-Json
|
||||
$conflictObject.severity = 'critical'
|
||||
$conflict = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body ($conflictObject | ConvertTo-Json -Depth 6 -Compress) -TimeoutSec 10 -NoProxy
|
||||
if ([int]$conflict.code -ne 409) { throw "idempotency conflict returned code $($conflict.code)" }
|
||||
|
||||
$malformed = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body '{' -TimeoutSec 10 -NoProxy
|
||||
if ([int]$malformed.code -ne 400) { throw "malformed request returned code $($malformed.code)" }
|
||||
$oversized = @{ sourceEventId='too-large'; eventType='test'; occurredAt=$occurredAt; location='lab'; severity='low'; attributes=@{ blob=('x' * 70000) } } | ConvertTo-Json -Depth 5 -Compress
|
||||
$tooLarge = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body $oversized -TimeoutSec 10 -NoProxy
|
||||
if ([int]$tooLarge.code -ne 400) { throw "oversized request returned code $($tooLarge.code)" }
|
||||
|
||||
$psql = Join-Path $PostgresBin 'psql.exe'
|
||||
$counts = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from bell_events; select count(*) from bell_event_receipts; select count(*) from bell_event_ingest_audits where outcome='accepted'; select count(*) from bell_event_ingest_audits where outcome='replay'; select count(*) from bell_event_ingest_audits where outcome='conflict';"
|
||||
if (($counts -join ',') -ne '2,2,2,12,1') { throw "unexpected Bell fact counts: $($counts -join ',')" }
|
||||
$payloadAuditCount = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from sys_opera_log where oper_url='/api/v1/bell/synthetic-events' and (oper_param like '%sourceEventId%' or json_result like '%acceptance-001%');"
|
||||
if ([int]$payloadAuditCount -ne 0) { throw 'generic GoAdmin audit retained synthetic event payload' }
|
||||
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "update bell_events set severity='low' where id='$eventId'" 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { throw 'immutable Event update unexpectedly succeeded' }
|
||||
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "delete from bell_event_receipts where id='$receiptId'" 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { throw 'immutable Receipt delete unexpectedly succeeded' }
|
||||
|
||||
Stop-Bell
|
||||
Start-Bell $baseUrl
|
||||
$afterRestart = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/events/$eventId" -Headers $headers -TimeoutSec 10 -NoProxy
|
||||
if ([int]$afterRestart.code -ne 200 -or $afterRestart.data.id -ne $eventId) { throw 'Event was not readable after Bell restart' }
|
||||
|
||||
Stop-Bell
|
||||
Start-Bell $baseUrl 'config/settings.yml'
|
||||
$productionSynthetic = Invoke-WebRequest -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 10 -NoProxy -SkipHttpErrorCheck
|
||||
if ([int]$productionSynthetic.StatusCode -ne 404) { throw "production synthetic route returned HTTP $($productionSynthetic.StatusCode)" }
|
||||
|
||||
Write-Output "BELL_131_SMOKE events=2 receipts=2 concurrent=12 audit=redacted immutable=true restart=true production_synthetic=404"
|
||||
} finally {
|
||||
Stop-Bell
|
||||
if ($pgStarted) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT','BELL_SYNTHETIC_EVENTS_ENABLED')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Verbose "Bell #131 temporary artifacts: $testRoot"
|
||||
}
|
||||
@@ -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
|
||||
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
|
||||
e.Error(500, err, "服务初始化失败!")
|
||||
return
|
||||
}
|
||||
id, b64s, answer, err := captcha.DriverDigitFunc()
|
||||
id, b64s, _, err := captcha.DriverDigitFunc()
|
||||
if err != nil {
|
||||
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
|
||||
e.Error(500, err, "验证码获取失败")
|
||||
return
|
||||
}
|
||||
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
|
||||
e.Custom(gin.H{
|
||||
"code": 200,
|
||||
"data": b64s,
|
||||
|
||||
@@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
}
|
||||
|
||||
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
systemAPI := apis.System{}
|
||||
v1.GET("/captcha", systemAPI.GenerateCaptchaHandler)
|
||||
|
||||
api := apis.SysMenu{}
|
||||
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
|
||||
"go-admin/app/bell/evaluation"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var query PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.Logger.Errorf("list Bell alerts failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
|
||||
return
|
||||
}
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
h.PageOK(items, int(count), page, size, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) Get(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
|
||||
return
|
||||
}
|
||||
h.Logger.Errorf("get Bell alert failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
|
||||
return
|
||||
}
|
||||
h.OK(detail, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) ListEvents(c *gin.Context) {
|
||||
var query EventPageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).ListEvents(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.Logger.Errorf("list Bell events failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
|
||||
return
|
||||
}
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
h.PageOK(items, int(count), page, size, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) EventResults(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
result, err := evaluation.NewService(h.Orm).ForEvent(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, evaluation.ErrEventNotFound) {
|
||||
h.Error(http.StatusNotFound, evaluation.ErrEventNotFound, evaluation.ErrEventNotFound.Error())
|
||||
return
|
||||
}
|
||||
h.Logger.Errorf("get Bell event rule results failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取规则评估失败"), "读取规则评估失败")
|
||||
return
|
||||
}
|
||||
h.OK(result, "查询成功")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
PrimaryRuleID string `json:"primaryRuleId" gorm:"type:uuid;not null;index"`
|
||||
CorrelationKey string `json:"-" gorm:"size:384;not null"`
|
||||
Status string `json:"status" gorm:"size:24;not null;default:open;index"`
|
||||
Severity string `json:"severity" gorm:"size:16;not null;index"`
|
||||
Summary string `json:"summary" gorm:"size:256;not null"`
|
||||
Location string `json:"location" gorm:"size:256;not null;index"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Alert) TableName() string { return "bell_alerts" }
|
||||
|
||||
type AlertEvent struct {
|
||||
AlertID string `json:"alertId" gorm:"type:uuid;primaryKey"`
|
||||
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
|
||||
LinkedAt time.Time `json:"linkedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AlertEvent) TableName() string { return "bell_alert_events" }
|
||||
|
||||
type RuleMatch struct {
|
||||
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
|
||||
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
|
||||
AlertID string `json:"alertId" gorm:"type:uuid;not null;index"`
|
||||
RuleVersion int `json:"ruleVersion" gorm:"not null"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
|
||||
Explanation string `json:"explanation" gorm:"size:512;not null"`
|
||||
MatchedAt time.Time `json:"matchedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (RuleMatch) TableName() string { return "bell_rule_matches" }
|
||||
@@ -0,0 +1,136 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("预警不存在")
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Status string `form:"status"`
|
||||
Severity string `form:"severity"`
|
||||
Location string `form:"location"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Alert
|
||||
RuleName string `json:"ruleName"`
|
||||
EventCount int64 `json:"eventCount"`
|
||||
}
|
||||
|
||||
type LinkedEvent struct {
|
||||
event.Event
|
||||
LinkedAt time.Time `json:"linkedAt"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Alert Summary `json:"alert"`
|
||||
Events []LinkedEvent `json:"events"`
|
||||
Matches []RuleMatch `json:"matches"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]Summary, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
base := s.DB.WithContext(ctx).Table("bell_alerts a")
|
||||
if query.Status = strings.TrimSpace(query.Status); query.Status != "" {
|
||||
base = base.Where("a.status = ?", query.Status)
|
||||
}
|
||||
if query.Severity = strings.TrimSpace(query.Severity); query.Severity != "" {
|
||||
base = base.Where("a.severity = ?", query.Severity)
|
||||
}
|
||||
if query.Location = strings.TrimSpace(query.Location); query.Location != "" {
|
||||
base = base.Where("a.location ILIKE ?", "%"+query.Location+"%")
|
||||
}
|
||||
var count int64
|
||||
if err := base.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]Summary, 0)
|
||||
err := base.Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
|
||||
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").
|
||||
Order("a.created_at DESC, a.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
|
||||
return items, count, err
|
||||
}
|
||||
|
||||
func (s Service) Get(ctx context.Context, id string) (Detail, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
detail := Detail{Events: make([]LinkedEvent, 0), Matches: make([]RuleMatch, 0)}
|
||||
db := s.DB.WithContext(ctx)
|
||||
err := db.Table("bell_alerts a").
|
||||
Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
|
||||
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").Where("a.id = ?", id).Take(&detail.Alert).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if err = db.Table("bell_events e").Select("e.*, ae.linked_at").
|
||||
Joins("JOIN bell_alert_events ae ON ae.event_id = e.id").
|
||||
Where("ae.alert_id = ?", id).Order("e.occurred_at, e.id").Scan(&detail.Events).Error; err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
err = db.Where("alert_id = ?", id).Order("matched_at, event_id, rule_id").Find(&detail.Matches).Error
|
||||
return detail, err
|
||||
}
|
||||
|
||||
type EventPageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
EventType string `form:"eventType"`
|
||||
Severity string `form:"severity"`
|
||||
Location string `form:"location"`
|
||||
}
|
||||
|
||||
type EventSummary struct {
|
||||
event.Event
|
||||
AlertCount int64 `json:"alertCount"`
|
||||
}
|
||||
|
||||
func (s Service) ListEvents(ctx context.Context, query EventPageQuery) ([]EventSummary, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&event.Event{})
|
||||
if value := strings.TrimSpace(query.EventType); value != "" {
|
||||
db = db.Where("event_type ILIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(query.Severity); value != "" {
|
||||
db = db.Where("severity = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(query.Location); value != "" {
|
||||
db = db.Where("location ILIKE ?", "%"+value+"%")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]EventSummary, 0)
|
||||
err := db.Select("bell_events.*, (SELECT count(*) FROM bell_alert_events ae WHERE ae.event_id = bell_events.id) AS alert_count").
|
||||
Order("occurred_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
|
||||
return items, count, err
|
||||
}
|
||||
|
||||
func pageValues(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func (h Handler) Get(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
|
||||
if err == nil {
|
||||
current := actor(c)
|
||||
detail.CanAck = detail.Projection.Status == StatusOpen && (current.Role == "admin" || current.Role == "operator")
|
||||
detail.CanClose = detail.Projection.Status == StatusAcknowledged && (current.Role == "admin" || (detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == current.ID))
|
||||
}
|
||||
h.respond(c, Result{Detail: detail}, err)
|
||||
}
|
||||
|
||||
func (h Handler) Ack(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
result, err := NewService(h.Orm).Ack(c.Request.Context(), c.Param("id"), actor(c))
|
||||
h.respond(c, result, err)
|
||||
}
|
||||
|
||||
func (h Handler) Close(c *gin.Context) {
|
||||
if err := restoreCloseBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var input CloseInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
result, err := NewService(h.Orm).Close(c.Request.Context(), c.Param("id"), input, actor(c))
|
||||
h.respond(c, result, err)
|
||||
}
|
||||
|
||||
func (h Handler) respond(c *gin.Context, result Result, err error) {
|
||||
if err == nil {
|
||||
h.OK(result, "操作成功")
|
||||
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
|
||||
return
|
||||
}
|
||||
code := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
code = http.StatusNotFound
|
||||
case errors.Is(err, ErrOutcomeRequired):
|
||||
code = http.StatusBadRequest
|
||||
case errors.Is(err, ErrAlreadyHandled), errors.Is(err, ErrInvalidTransition):
|
||||
code = http.StatusConflict
|
||||
case errors.Is(err, ErrForbidden):
|
||||
code = http.StatusForbidden
|
||||
default:
|
||||
h.Logger.Errorf("Bell alert lifecycle failed: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "data": result})
|
||||
c.Set("result", gin.H{"code": code, "data": "<redacted>"})
|
||||
}
|
||||
|
||||
func actor(c *gin.Context) Actor {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return Actor{ID: user.GetUserId(c), Name: user.GetUserName(c), Role: role}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StatusOpen = "open"
|
||||
StatusAcknowledged = "acknowledged"
|
||||
StatusClosed = "closed"
|
||||
)
|
||||
|
||||
type Projection struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Status string `json:"status"`
|
||||
AcknowledgedBy *int `json:"acknowledgedBy,omitempty"`
|
||||
AcknowledgedByName *string `json:"acknowledgedByName,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
|
||||
ClosedBy *int `json:"closedBy,omitempty"`
|
||||
ClosedByName *string `json:"closedByName,omitempty"`
|
||||
ClosedAt *time.Time `json:"closedAt,omitempty"`
|
||||
CloseOutcome *string `json:"closeOutcome,omitempty"`
|
||||
CloseNote *string `json:"closeNote,omitempty"`
|
||||
}
|
||||
|
||||
func (Projection) TableName() string { return "bell_alerts" }
|
||||
|
||||
type Fact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
AlertID string `json:"alertId" gorm:"type:uuid;not null;uniqueIndex:bell_alert_transition"`
|
||||
Transition string `json:"transition" gorm:"size:24;not null;uniqueIndex:bell_alert_transition"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
ActorName string `json:"actorName" gorm:"size:128;not null"`
|
||||
Outcome *string `json:"outcome,omitempty" gorm:"size:32"`
|
||||
Note *string `json:"note,omitempty" gorm:"size:500"`
|
||||
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (Fact) TableName() string { return "bell_alert_lifecycle_facts" }
|
||||
|
||||
type RejectionAudit struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
AlertID *string `json:"alertId,omitempty" gorm:"type:uuid;index"`
|
||||
Action string `json:"action" gorm:"size:16;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null;index"`
|
||||
Reason string `json:"reason" gorm:"size:64;not null"`
|
||||
ObservedStatus *string `json:"observedStatus,omitempty" gorm:"size:24"`
|
||||
ObservedActor *int `json:"observedActor,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (RejectionAudit) TableName() string { return "bell_alert_lifecycle_rejections" }
|
||||
@@ -0,0 +1,47 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxCloseRequestBytes = 8 * 1024
|
||||
const closeBodyKey = "bell.lifecycle.close-body"
|
||||
const closeBodyErrorKey = "bell.lifecycle.close-body-error"
|
||||
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || !strings.HasPrefix(c.Request.URL.Path, "/api/v1/bell/alerts/") || !strings.HasSuffix(c.Request.URL.Path, "/close") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxCloseRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(closeBodyErrorKey, err)
|
||||
} else if len(body) > maxCloseRequestBytes {
|
||||
c.Set(closeBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(closeBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader([]byte(`{"redacted":true}`)))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreCloseBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(closeBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(closeBodyKey)
|
||||
if !ok {
|
||||
return errors.New("close request body was not captured")
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Actor struct {
|
||||
ID int
|
||||
Name, Role string
|
||||
}
|
||||
type Detail struct {
|
||||
Projection Projection `json:"projection"`
|
||||
Timeline []Fact `json:"timeline"`
|
||||
CanAck bool `json:"canAck"`
|
||||
CanClose bool `json:"canClose"`
|
||||
}
|
||||
type Result struct {
|
||||
Detail Detail `json:"detail"`
|
||||
Idempotent bool `json:"idempotent"`
|
||||
Won bool `json:"won"`
|
||||
}
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) Get(ctx context.Context, alertID string) (Detail, error) {
|
||||
if _, err := uuid.Parse(alertID); err != nil {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
var projection Projection
|
||||
if err := s.DB.WithContext(ctx).First(&projection, "id = ?", alertID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
return Detail{}, err
|
||||
}
|
||||
facts := make([]Fact, 0)
|
||||
if err := s.DB.WithContext(ctx).Where("alert_id = ?", alertID).Order("occurred_at, id").Find(&facts).Error; err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
return Detail{Projection: projection, Timeline: facts}, nil
|
||||
}
|
||||
|
||||
func (s Service) Ack(ctx context.Context, alertID string, actor Actor) (Result, error) {
|
||||
if _, err := uuid.Parse(alertID); err != nil {
|
||||
s.reject(ctx, nil, "ack", actor.ID, "not_found", nil)
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var projection Projection
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Raw(`UPDATE bell_alerts SET status='acknowledged', acknowledged_by=?, acknowledged_by_name=?, acknowledged_at=?, updated_at=? WHERE id=? AND status='open' RETURNING id,status,acknowledged_by,acknowledged_by_name,acknowledged_at,closed_by,closed_by_name,closed_at,close_outcome,close_note`, actor.ID, actor.Name, now, now, alertID).Scan(&projection)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusAcknowledged, ActorID: actor.ID, ActorName: actor.Name, OccurredAt: now}).Error
|
||||
})
|
||||
if err == nil {
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
return Result{Detail: detail, Won: true}, getErr
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
if getErr != nil {
|
||||
return Result{}, getErr
|
||||
}
|
||||
if detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == actor.ID {
|
||||
s.reject(ctx, &alertID, "ack", actor.ID, "duplicate", &detail.Projection)
|
||||
return Result{Detail: detail, Idempotent: true}, nil
|
||||
}
|
||||
s.reject(ctx, &alertID, "ack", actor.ID, "already_handled", &detail.Projection)
|
||||
return Result{Detail: detail}, ErrAlreadyHandled
|
||||
}
|
||||
|
||||
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor Actor) (Result, error) {
|
||||
normalized, err := normalizeClose(input)
|
||||
if err != nil {
|
||||
s.reject(ctx, validAlertID(alertID), "close", actor.ID, "invalid_outcome", nil)
|
||||
return Result{}, err
|
||||
}
|
||||
if _, err = uuid.Parse(alertID); err != nil {
|
||||
s.reject(ctx, nil, "close", actor.ID, "not_found", nil)
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
var projection Projection
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if lockErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&projection, "id = ?", alertID).Error; lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if projection.Status == StatusClosed {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
if projection.Status != StatusAcknowledged {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
if actor.Role != "admin" && (projection.AcknowledgedBy == nil || *projection.AcknowledgedBy != actor.ID) {
|
||||
return ErrForbidden
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var note *string
|
||||
if normalized.Note != "" {
|
||||
note = &normalized.Note
|
||||
}
|
||||
if updateErr := tx.Model(&projection).Updates(map[string]any{"status": StatusClosed, "closed_by": actor.ID, "closed_by_name": actor.Name, "closed_at": now, "close_outcome": normalized.Outcome, "close_note": note, "updated_at": now}).Error; updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusClosed, ActorID: actor.ID, ActorName: actor.Name, Outcome: &normalized.Outcome, Note: note, OccurredAt: now}).Error
|
||||
})
|
||||
if err == nil {
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
return Result{Detail: detail, Won: true}, getErr
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidTransition) && !errors.Is(err, ErrForbidden) && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, getErr := s.Get(ctx, alertID)
|
||||
if getErr != nil {
|
||||
return Result{}, getErr
|
||||
}
|
||||
if detail.Projection.Status == StatusClosed && detail.Projection.ClosedBy != nil && *detail.Projection.ClosedBy == actor.ID && detail.Projection.CloseOutcome != nil && *detail.Projection.CloseOutcome == normalized.Outcome && equalOptional(detail.Projection.CloseNote, normalized.Note) {
|
||||
s.reject(ctx, &alertID, "close", actor.ID, "duplicate", &detail.Projection)
|
||||
return Result{Detail: detail, Idempotent: true}, nil
|
||||
}
|
||||
reason := "invalid_transition"
|
||||
publicErr := ErrInvalidTransition
|
||||
if errors.Is(err, ErrForbidden) {
|
||||
reason, publicErr = "forbidden", ErrForbidden
|
||||
} else if detail.Projection.Status == StatusClosed {
|
||||
reason = "conflicting_replay"
|
||||
}
|
||||
s.reject(ctx, &alertID, "close", actor.ID, reason, &detail.Projection)
|
||||
return Result{Detail: detail}, publicErr
|
||||
}
|
||||
|
||||
func (s Service) reject(ctx context.Context, alertID *string, action string, actorID int, reason string, projection *Projection) {
|
||||
audit := RejectionAudit{ID: uuid.NewString(), AlertID: alertID, Action: action, ActorID: actorID, Reason: reason, CreatedAt: time.Now().UTC()}
|
||||
if projection != nil {
|
||||
audit.ObservedStatus = &projection.Status
|
||||
audit.ObservedActor = projection.AcknowledgedBy
|
||||
}
|
||||
_ = s.DB.WithContext(ctx).Create(&audit).Error
|
||||
}
|
||||
func validAlertID(value string) *string {
|
||||
if _, err := uuid.Parse(value); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
func equalOptional(value *string, other string) bool {
|
||||
if value == nil {
|
||||
return other == ""
|
||||
}
|
||||
return *value == other
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package alert_lifecycle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("预警不存在")
|
||||
ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
|
||||
ErrInvalidTransition = errors.New("当前状态不能执行此操作")
|
||||
ErrOutcomeRequired = errors.New("请选择有效的现场结果")
|
||||
ErrForbidden = errors.New("您无权完成此预警")
|
||||
)
|
||||
|
||||
type CloseInput struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func normalizeClose(input CloseInput) (CloseInput, error) {
|
||||
input.Outcome = strings.TrimSpace(input.Outcome)
|
||||
input.Note = strings.TrimSpace(input.Note)
|
||||
switch input.Outcome {
|
||||
case "danger_confirmed", "false_positive", "site_normal", "unable_to_confirm":
|
||||
default:
|
||||
return CloseInput{}, ErrOutcomeRequired
|
||||
}
|
||||
if !utf8.ValidString(input.Note) || utf8.RuneCountInString(input.Note) > 500 || strings.ContainsAny(input.Note, "\x00\r") {
|
||||
return CloseInput{}, ErrOutcomeRequired
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrChannelKeyUnavailable = errors.New("联系人通道加密密钥未配置或格式错误")
|
||||
|
||||
func ParseChannelKey(value string) ([]byte, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, ErrChannelKeyUnavailable
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptAddress(key []byte, value string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, ErrChannelKeyUnavailable
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(value), nil), nil
|
||||
}
|
||||
|
||||
func decryptAddress(key, encoded []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", ErrChannelKeyUnavailable
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(encoded) < gcm.NonceSize() {
|
||||
return "", errors.New("通道密文已损坏")
|
||||
}
|
||||
plain, err := gcm.Open(nil, encoded[:gcm.NonceSize()], encoded[gcm.NonceSize():], nil)
|
||||
return string(plain), err
|
||||
}
|
||||
|
||||
func fingerprint(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
type enabledInput struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
ExpectedVersion int `json:"expectedVersion" binding:"required"`
|
||||
}
|
||||
type validationInput struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func (h Handler) service() (Service, error) {
|
||||
key, err := ParseChannelKey(os.Getenv("BELL_CONTACT_CHANNEL_KEY"))
|
||||
return NewService(h.Orm, key), err
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var q PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm, nil).List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
h.Error(500, errors.New("读取联系人失败"), "读取联系人失败")
|
||||
return
|
||||
}
|
||||
p, s := pageValues(q.PageIndex, q.PageSize)
|
||||
h.PageOK(items, int(count), p, s, "查询成功")
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).Create(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) Update(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input enabledInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil || input.Enabled == nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, input.ExpectedVersion, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) AddChannel(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护通道"), "仅管理员可维护通道")
|
||||
return
|
||||
}
|
||||
if err := restoreChannelBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
var input ChannelInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
service, keyErr := h.service()
|
||||
if keyErr != nil {
|
||||
h.Error(503, keyErr, keyErr.Error())
|
||||
return
|
||||
}
|
||||
item, err := service.AddChannel(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) ValidateChannel(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可验证通道"), "仅管理员可验证通道")
|
||||
return
|
||||
}
|
||||
var input validationInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).RecordValidation(c.Request.Context(), c.Param("id"), input.Status, input.Detail, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) result(item any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid):
|
||||
h.Error(400, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(404, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.Error(409, err, err.Error())
|
||||
case errors.Is(err, ErrChannelKeyUnavailable):
|
||||
h.Error(503, err, err.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell contact failed: %v", err)
|
||||
h.Error(409, errors.New("联系人保存失败"), "联系人保存失败")
|
||||
}
|
||||
}
|
||||
func admin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
var _ = http.StatusOK
|
||||
@@ -0,0 +1,68 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
Role string `json:"role" gorm:"size:128;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
Version int `json:"version" gorm:"not null;default:1"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
UpdatedBy int `json:"updatedBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Contact) TableName() string { return "bell_contacts" }
|
||||
|
||||
type Channel struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
|
||||
Kind string `json:"kind" gorm:"size:16;not null"`
|
||||
AddressCiphertext []byte `json:"-" gorm:"type:bytea;not null"`
|
||||
AddressFingerprint string `json:"-" gorm:"size:64;not null;index"`
|
||||
AddressMasked string `json:"addressMasked" gorm:"size:64;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Channel) TableName() string { return "bell_contact_channels" }
|
||||
|
||||
type ChannelValidation struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ChannelID string `json:"channelId" gorm:"type:uuid;not null;index"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
Detail string `json:"detail" gorm:"size:256;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (ChannelValidation) TableName() string { return "bell_contact_channel_validations" }
|
||||
|
||||
type AuditFact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
|
||||
Action string `json:"action" gorm:"size:32;not null"`
|
||||
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AuditFact) TableName() string { return "bell_contact_audit_facts" }
|
||||
|
||||
type ChannelView struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
AddressMasked string `json:"addressMasked"`
|
||||
Status string `json:"status"`
|
||||
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ContactView struct {
|
||||
Contact
|
||||
Channels []ChannelView `json:"channels"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxChannelRequestBytes = 8 * 1024
|
||||
const channelBodyKey = "bell.contact.channel-body"
|
||||
const channelBodyErrorKey = "bell.contact.channel-body-error"
|
||||
|
||||
var redactedChannelBody = []byte(`{"redacted":true}`)
|
||||
|
||||
// RedactRequestBody must run before GoAdmin's LoggerToFile middleware. The
|
||||
// handler restores the original body from Gin context, while sys_opera_log
|
||||
// only sees a fixed marker and never the contact address.
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || !isChannelCreatePath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxChannelRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(channelBodyErrorKey, err)
|
||||
} else if len(body) > maxChannelRequestBytes {
|
||||
c.Set(channelBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(channelBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(redactedChannelBody))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreChannelBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(channelBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(channelBodyKey)
|
||||
if !ok {
|
||||
return errors.New("channel request body was not captured")
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func isChannelCreatePath(path string) bool {
|
||||
const prefix = "/api/v1/bell/contacts/"
|
||||
const suffix = "/channels"
|
||||
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) {
|
||||
return false
|
||||
}
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
|
||||
return id != "" && !strings.Contains(id, "/")
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Name string `form:"name"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
}
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, key []byte) Service { return Service{DB: db, Key: key} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]ContactView, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Contact{})
|
||||
if name := strings.TrimSpace(query.Name); name != "" {
|
||||
db = db.Where("name ILIKE ? OR role ILIKE ?", "%"+name+"%", "%"+name+"%")
|
||||
}
|
||||
if query.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *query.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var contacts []Contact
|
||||
if err := db.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&contacts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views := make([]ContactView, 0, len(contacts))
|
||||
for _, item := range contacts {
|
||||
view, err := s.view(ctx, item)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return views, count, nil
|
||||
}
|
||||
|
||||
func (s Service) Create(ctx context.Context, input WriteInput, actor int) (ContactView, error) {
|
||||
input, err := normalizeContact(input)
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Contact{ID: uuid.NewString(), Name: input.Name, Role: input.Role, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, "created", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return ContactView{Contact: item, Channels: []ChannelView{}}, nil
|
||||
}
|
||||
|
||||
func (s Service) Update(ctx context.Context, id string, input WriteInput, actor int) (ContactView, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return ContactView{}, ErrNotFound
|
||||
}
|
||||
input, err := normalizeContact(input)
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
if input.ExpectedVersion < 1 {
|
||||
return ContactView{}, ErrInvalid
|
||||
}
|
||||
var item Contact
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "role": input.Role, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
var count int64
|
||||
_ = tx.Model(&Contact{}).Where("id = ?", id).Count(&count).Error
|
||||
if count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, "updated", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, expectedVersion, actor int) (ContactView, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return ContactView{}, ErrNotFound
|
||||
}
|
||||
if expectedVersion < 1 {
|
||||
return ContactView{}, ErrInvalid
|
||||
}
|
||||
var item Contact
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, expectedVersion).Updates(map[string]any{"enabled": enabled, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, map[bool]string{true: "enabled", false: "disabled"}[enabled], actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
|
||||
func (s Service) AddChannel(ctx context.Context, contactID string, input ChannelInput, actor int) (ChannelView, error) {
|
||||
if len(s.Key) != 32 {
|
||||
return ChannelView{}, ErrChannelKeyUnavailable
|
||||
}
|
||||
if _, err := uuid.Parse(contactID); err != nil {
|
||||
return ChannelView{}, ErrNotFound
|
||||
}
|
||||
input, err := normalizeChannel(input)
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
ciphertext, err := encryptAddress(s.Key, input.Address)
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Channel{ID: uuid.NewString(), ContactID: contactID, Kind: input.Kind, AddressCiphertext: ciphertext, AddressFingerprint: fingerprint(input.Address), AddressMasked: maskAddress(input.Address), CreatedBy: actor, CreatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var contact Contact
|
||||
if err := tx.First(&contact, "id = ?", contactID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, contact, "channel_added", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
return ChannelView{ID: item.ID, Kind: item.Kind, AddressMasked: item.AddressMasked, Status: "pending"}, nil
|
||||
}
|
||||
|
||||
func (s Service) RecordValidation(ctx context.Context, channelID, status, detail string, actor int) (ChannelView, error) {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
detail = strings.TrimSpace(detail)
|
||||
if status != "verified" && status != "failed" {
|
||||
return ChannelView{}, ErrInvalid
|
||||
}
|
||||
if len([]rune(detail)) > 256 || hasControl(detail) {
|
||||
return ChannelView{}, ErrInvalid
|
||||
}
|
||||
var channel Channel
|
||||
fact := ChannelValidation{ID: uuid.NewString(), ChannelID: channelID, Status: status, Detail: detail, ActorID: actor, CreatedAt: time.Now().UTC()}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&channel, "id = ?", channelID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&fact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var c Contact
|
||||
if err := tx.First(&c, "id = ?", channel.ContactID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, c, "channel_validation_"+status, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
return ChannelView{ID: channel.ID, Kind: channel.Kind, AddressMasked: channel.AddressMasked, Status: status, ValidatedAt: &fact.CreatedAt}, nil
|
||||
}
|
||||
|
||||
// DecryptChannelAddress is intentionally server-only. API responses never expose this value.
|
||||
func (s Service) DecryptChannelAddress(ctx context.Context, channelID string) (string, error) {
|
||||
var item Channel
|
||||
if err := s.DB.WithContext(ctx).First(&item, "id = ?", channelID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decryptAddress(s.Key, item.AddressCiphertext)
|
||||
}
|
||||
|
||||
func (s Service) view(ctx context.Context, item Contact) (ContactView, error) {
|
||||
var channels []Channel
|
||||
if err := s.DB.WithContext(ctx).Where("contact_id = ?", item.ID).Order("created_at,id").Find(&channels).Error; err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
views := make([]ChannelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
view := ChannelView{ID: ch.ID, Kind: ch.Kind, AddressMasked: ch.AddressMasked, Status: "pending"}
|
||||
var fact ChannelValidation
|
||||
err := s.DB.WithContext(ctx).Where("channel_id = ?", ch.ID).Order("created_at DESC,id DESC").Take(&fact).Error
|
||||
if err == nil {
|
||||
view.Status = fact.Status
|
||||
view.ValidatedAt = &fact.CreatedAt
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ContactView{}, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return ContactView{Contact: item, Channels: views}, nil
|
||||
}
|
||||
|
||||
func appendAudit(tx *gorm.DB, item Contact, action string, actor int) error {
|
||||
snapshot, err := json.Marshal(map[string]any{"id": item.ID, "name": item.Name, "role": item.Role, "enabled": item.Enabled, "version": item.Version})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&AuditFact{ID: uuid.NewString(), ContactID: item.ID, Action: action, Snapshot: snapshot, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
func pageValues(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("联系人或通道信息不符合要求")
|
||||
ErrNotFound = errors.New("联系人或通道不存在")
|
||||
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
|
||||
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
)
|
||||
|
||||
type WriteInput struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
|
||||
type ChannelInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func normalizeContact(input WriteInput) (WriteInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Role = strings.TrimSpace(input.Role)
|
||||
if input.Name == "" || len([]rune(input.Name)) > 128 || input.Role == "" || len([]rune(input.Role)) > 128 || hasControl(input.Name+input.Role) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func normalizeChannel(input ChannelInput) (ChannelInput, error) {
|
||||
input.Kind = strings.ToLower(strings.TrimSpace(input.Kind))
|
||||
input.Address = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(input.Address), " ", ""), "-", "")
|
||||
if (input.Kind != "sms" && input.Kind != "voice") || !phonePattern.MatchString(input.Address) {
|
||||
return ChannelInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func maskAddress(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return strings.Repeat("*", min(8, len(runes)-4)) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func hasControl(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 32 || r == 127 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var q PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
h.Error(500, errors.New("读取排班失败"), "读取排班失败")
|
||||
return
|
||||
}
|
||||
p, s := pageValues(q.PageIndex, q.PageSize)
|
||||
h.PageOK(items, int(count), p, s, "查询成功")
|
||||
}
|
||||
func (h Handler) CreateGroup(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input GroupInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateGroup(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) UpdateGroup(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input GroupInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).UpdateGroup(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) AddMember(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input MemberInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).AddMember(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) CreateSchedule(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input ScheduleInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateSchedule(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) Publish(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可发布排班"), "仅管理员可发布排班")
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Publish(c.Request.Context(), c.Param("id"), user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) CreateOverride(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护替班"), "仅管理员可维护替班")
|
||||
return
|
||||
}
|
||||
var input OverrideInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateOverride(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h *Handler) bind(c *gin.Context, value any) {
|
||||
h.MakeContext(c).MakeOrm().Bind(value, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
}
|
||||
}
|
||||
func (h Handler) result(item any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid) || errors.Is(err, ErrCoverage):
|
||||
h.Error(400, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(404, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.Error(409, err, err.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell duty schedule failed: %v", err)
|
||||
h.Error(409, errors.New("排班保存失败"), "排班保存失败")
|
||||
}
|
||||
}
|
||||
func admin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
Version int `json:"version" gorm:"not null;default:1"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
UpdatedBy int `json:"updatedBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Group) TableName() string { return "bell_duty_groups" }
|
||||
|
||||
type Member struct {
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;primaryKey"`
|
||||
Role string `json:"role" gorm:"size:16;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Member) TableName() string { return "bell_duty_members" }
|
||||
|
||||
type ScheduleVersion struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null"`
|
||||
EffectiveFrom time.Time `json:"effectiveFrom" gorm:"type:timestamptz;not null"`
|
||||
EffectiveTo *time.Time `json:"effectiveTo,omitempty" gorm:"type:timestamptz"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
PublishedBy *int `json:"publishedBy,omitempty"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty" gorm:"type:timestamptz"`
|
||||
}
|
||||
|
||||
func (ScheduleVersion) TableName() string { return "bell_duty_schedule_versions" }
|
||||
|
||||
type RotationSlot struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ScheduleVersionID string `json:"scheduleVersionId" gorm:"type:uuid;not null;index"`
|
||||
Weekday int `json:"weekday" gorm:"not null"`
|
||||
StartMinute int `json:"startMinute" gorm:"not null"`
|
||||
EndMinute int `json:"endMinute" gorm:"not null"`
|
||||
PrimaryContactID string `json:"primaryContactId" gorm:"type:uuid;not null"`
|
||||
BackupContactID string `json:"backupContactId" gorm:"type:uuid;not null"`
|
||||
}
|
||||
|
||||
func (RotationSlot) TableName() string { return "bell_duty_rotation_slots" }
|
||||
|
||||
type Override struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
OriginalContactID string `json:"originalContactId" gorm:"type:uuid;not null"`
|
||||
ReplacementContactID string `json:"replacementContactId" gorm:"type:uuid;not null"`
|
||||
StartsAt time.Time `json:"startsAt" gorm:"type:timestamptz;not null;index"`
|
||||
EndsAt time.Time `json:"endsAt" gorm:"type:timestamptz;not null"`
|
||||
Reason string `json:"reason" gorm:"size:256;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Override) TableName() string { return "bell_duty_overrides" }
|
||||
|
||||
type AuditFact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
Action string `json:"action" gorm:"size:32;not null"`
|
||||
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AuditFact) TableName() string { return "bell_duty_audit_facts" }
|
||||
|
||||
type GroupView struct {
|
||||
Group
|
||||
Members []Member `json:"members"`
|
||||
Schedules []ScheduleView `json:"schedules"`
|
||||
Overrides []Override `json:"overrides"`
|
||||
}
|
||||
type ScheduleView struct {
|
||||
ScheduleVersion
|
||||
Slots []RotationSlot `json:"slots"`
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
)
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Name string `form:"name"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
}
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
func (s Service) List(ctx context.Context, q PageQuery) ([]GroupView, int64, error) {
|
||||
p, z := pageValues(q.PageIndex, q.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Group{})
|
||||
if name := strings.TrimSpace(q.Name); name != "" {
|
||||
db = db.Where("name ILIKE ?", "%"+name+"%")
|
||||
}
|
||||
if q.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *q.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var groups []Group
|
||||
if err := db.Order("created_at DESC,id DESC").Offset((p - 1) * z).Limit(z).Find(&groups).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views := make([]GroupView, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
v, err := s.view(ctx, g)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
return views, count, nil
|
||||
}
|
||||
func (s Service) CreateGroup(ctx context.Context, input GroupInput, actor int) (GroupView, error) {
|
||||
input, err := normalizeGroup(input)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Group{ID: uuid.NewString(), Name: input.Name, Timezone: input.Timezone, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, item.ID, "group_created", item, actor)
|
||||
})
|
||||
return GroupView{Group: item, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}, err
|
||||
}
|
||||
func (s Service) UpdateGroup(ctx context.Context, id string, input GroupInput, actor int) (GroupView, error) {
|
||||
input, err := normalizeGroup(input)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
if input.ExpectedVersion < 1 {
|
||||
return GroupView{}, ErrInvalid
|
||||
}
|
||||
var item Group
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
r := tx.Model(&Group{}).Where("id=? AND version=?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "timezone": input.Timezone, "version": gorm.Expr("version+1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id=?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, id, "group_updated", item, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
func (s Service) AddMember(ctx context.Context, groupID string, input MemberInput, actor int) (Member, error) {
|
||||
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
|
||||
if input.Role != "primary" && input.Role != "backup" {
|
||||
return Member{}, ErrInvalid
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Member{GroupID: groupID, ContactID: input.ContactID, Role: input.Role, CreatedBy: actor, CreatedAt: now}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := assertEnabledContact(tx, input.ContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.First(&Group{}, "id=?", groupID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_id"}, {Name: "contact_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role", "created_by", "created_at"})}).Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, groupID, "member_saved", item, actor)
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
func (s Service) CreateSchedule(ctx context.Context, groupID string, input ScheduleInput, actor int) (ScheduleView, error) {
|
||||
if input.EffectiveFrom.IsZero() || (input.EffectiveTo != nil && !input.EffectiveTo.After(input.EffectiveFrom)) {
|
||||
return ScheduleView{}, ErrInvalid
|
||||
}
|
||||
if err := validateSlots(input.Slots); err != nil {
|
||||
return ScheduleView{}, err
|
||||
}
|
||||
var result ScheduleView
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var group Group
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, "id=?", groupID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, slot := range input.Slots {
|
||||
if err := assertGroupMember(tx, groupID, slot.PrimaryContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertGroupMember(tx, groupID, slot.BackupContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var latest int
|
||||
tx.Model(&ScheduleVersion{}).Where("group_id=?", groupID).Select("coalesce(max(version),0)").Scan(&latest)
|
||||
now := time.Now().UTC()
|
||||
version := ScheduleVersion{ID: uuid.NewString(), GroupID: groupID, Version: latest + 1, Timezone: group.Timezone, EffectiveFrom: input.EffectiveFrom.UTC(), EffectiveTo: input.EffectiveTo, Status: "draft", CreatedBy: actor, CreatedAt: now}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slots := make([]RotationSlot, 0, len(input.Slots))
|
||||
for _, in := range input.Slots {
|
||||
slots = append(slots, RotationSlot{ID: uuid.NewString(), ScheduleVersionID: version.ID, Weekday: in.Weekday, StartMinute: in.StartMinute, EndMinute: in.EndMinute, PrimaryContactID: in.PrimaryContactID, BackupContactID: in.BackupContactID})
|
||||
}
|
||||
if err := tx.Create(&slots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := audit(tx, groupID, "schedule_created", version, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ScheduleView{ScheduleVersion: version, Slots: slots}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
func (s Service) Publish(ctx context.Context, id string, actor int) (ScheduleView, error) {
|
||||
var result ScheduleView
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item ScheduleVersion
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id=?", id).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if item.Status != "draft" {
|
||||
return ErrConflict
|
||||
}
|
||||
var slots []RotationSlot
|
||||
if err := tx.Where("schedule_version_id=?", id).Find(&slots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
inputs := make([]SlotInput, 0, len(slots))
|
||||
for _, v := range slots {
|
||||
inputs = append(inputs, SlotInput{Weekday: v.Weekday, StartMinute: v.StartMinute, EndMinute: v.EndMinute, PrimaryContactID: v.PrimaryContactID, BackupContactID: v.BackupContactID})
|
||||
}
|
||||
if err := validateSlots(inputs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range slots {
|
||||
if err := assertVerifiedContact(tx, v.PrimaryContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertVerifiedContact(tx, v.BackupContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&item).Updates(map[string]any{"status": "published", "published_by": actor, "published_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
item.Status = "published"
|
||||
item.PublishedBy = &actor
|
||||
item.PublishedAt = &now
|
||||
if err := audit(tx, item.GroupID, "schedule_published", item, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ScheduleView{ScheduleVersion: item, Slots: slots}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
func (s Service) CreateOverride(ctx context.Context, groupID string, input OverrideInput, actor int) (Override, error) {
|
||||
input, err := normalizeOverride(input)
|
||||
if err != nil {
|
||||
return Override{}, err
|
||||
}
|
||||
item := Override{ID: uuid.NewString(), GroupID: groupID, OriginalContactID: input.OriginalContactID, ReplacementContactID: input.ReplacementContactID, StartsAt: input.StartsAt.UTC(), EndsAt: input.EndsAt.UTC(), Reason: input.Reason, CreatedBy: actor, CreatedAt: time.Now().UTC()}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := assertGroupMember(tx, groupID, input.OriginalContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertGroupMember(tx, groupID, input.ReplacementContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var overlaps int64
|
||||
if err := tx.Model(&Override{}).Where("group_id=? AND original_contact_id=? AND starts_at < ? AND ends_at > ?", groupID, input.OriginalContactID, item.EndsAt, item.StartsAt).Count(&overlaps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if overlaps > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, groupID, "override_created", item, actor)
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
func (s Service) view(ctx context.Context, g Group) (GroupView, error) {
|
||||
v := GroupView{Group: g, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("role,contact_id").Find(&v.Members).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
var versions []ScheduleVersion
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("version DESC").Find(&versions).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
for _, sv := range versions {
|
||||
var slots []RotationSlot
|
||||
if err := s.DB.WithContext(ctx).Where("schedule_version_id=?", sv.ID).Order("weekday,start_minute").Find(&slots).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Schedules = append(v.Schedules, ScheduleView{ScheduleVersion: sv, Slots: slots})
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("starts_at DESC").Limit(50).Find(&v.Overrides).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func assertEnabledContact(tx *gorm.DB, id string) error {
|
||||
var c contact.Contact
|
||||
if err := tx.Where("id=? AND enabled=true", id).First(&c).Error; err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func assertGroupMember(tx *gorm.DB, groupID, contactID string) error {
|
||||
if err := assertEnabledContact(tx, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&Member{}).Where("group_id=? AND contact_id=?", groupID, contactID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func assertVerifiedContact(tx *gorm.DB, contactID string) error {
|
||||
if err := assertEnabledContact(tx, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
err := tx.Raw(`SELECT count(*) FROM bell_contact_channels c WHERE c.contact_id=? AND (SELECT v.status FROM bell_contact_channel_validations v WHERE v.channel_id=c.id ORDER BY v.created_at DESC,v.id DESC LIMIT 1)='verified'`, contactID).Scan(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func audit(tx *gorm.DB, groupID, action string, value any, actor int) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&AuditFact{ID: uuid.NewString(), GroupID: groupID, Action: action, Snapshot: data, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
func pageValues(p, s int) (int, int) {
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if s < 1 || s > 100 {
|
||||
s = 20
|
||||
}
|
||||
return p, s
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("值班排班信息不符合要求")
|
||||
ErrNotFound = errors.New("值班组或排班不存在")
|
||||
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
|
||||
ErrCoverage = errors.New("周排班存在空档或重叠")
|
||||
)
|
||||
|
||||
type GroupInput struct {
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
type MemberInput struct {
|
||||
ContactID string `json:"contactId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
type SlotInput struct {
|
||||
Weekday int `json:"weekday"`
|
||||
StartMinute int `json:"startMinute"`
|
||||
EndMinute int `json:"endMinute"`
|
||||
PrimaryContactID string `json:"primaryContactId"`
|
||||
BackupContactID string `json:"backupContactId"`
|
||||
}
|
||||
type ScheduleInput struct {
|
||||
EffectiveFrom time.Time `json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `json:"effectiveTo"`
|
||||
Slots []SlotInput `json:"slots"`
|
||||
}
|
||||
type OverrideInput struct {
|
||||
OriginalContactID string `json:"originalContactId"`
|
||||
ReplacementContactID string `json:"replacementContactId"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func normalizeGroup(input GroupInput) (GroupInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Timezone = strings.TrimSpace(input.Timezone)
|
||||
if input.Name == "" || len([]rune(input.Name)) > 128 {
|
||||
return GroupInput{}, ErrInvalid
|
||||
}
|
||||
if _, err := time.LoadLocation(input.Timezone); err != nil {
|
||||
return GroupInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
func validateSlots(slots []SlotInput) error {
|
||||
if len(slots) == 0 {
|
||||
return ErrCoverage
|
||||
}
|
||||
byDay := map[int][]SlotInput{}
|
||||
for _, slot := range slots {
|
||||
if slot.Weekday < 0 || slot.Weekday > 6 || slot.StartMinute < 0 || slot.EndMinute > 1440 || slot.StartMinute >= slot.EndMinute || slot.PrimaryContactID == "" || slot.BackupContactID == "" || slot.PrimaryContactID == slot.BackupContactID {
|
||||
return ErrInvalid
|
||||
}
|
||||
byDay[slot.Weekday] = append(byDay[slot.Weekday], slot)
|
||||
}
|
||||
for day := 0; day < 7; day++ {
|
||||
daySlots := byDay[day]
|
||||
sort.Slice(daySlots, func(i, j int) bool { return daySlots[i].StartMinute < daySlots[j].StartMinute })
|
||||
cursor := 0
|
||||
for _, slot := range daySlots {
|
||||
if slot.StartMinute != cursor {
|
||||
return ErrCoverage
|
||||
}
|
||||
cursor = slot.EndMinute
|
||||
}
|
||||
if cursor != 1440 {
|
||||
return ErrCoverage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeOverride(input OverrideInput) (OverrideInput, error) {
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
if input.OriginalContactID == "" || input.ReplacementContactID == "" || input.OriginalContactID == input.ReplacementContactID || input.StartsAt.IsZero() || !input.EndsAt.After(input.StartsAt) || input.Reason == "" || len([]rune(input.Reason)) > 256 {
|
||||
return OverrideInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package evaluation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Evaluation is an immutable explanation of one rule version evaluated
|
||||
// against one Event.
|
||||
type Evaluation struct {
|
||||
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
|
||||
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
|
||||
RuleVersion int `json:"ruleVersion" gorm:"not null"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
|
||||
Matched bool `json:"matched" gorm:"not null"`
|
||||
Explanation string `json:"explanation" gorm:"size:512;not null"`
|
||||
EvaluatedAt time.Time `json:"evaluatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Evaluation) TableName() string { return "bell_rule_evaluations" }
|
||||
@@ -0,0 +1,50 @@
|
||||
package evaluation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrEventNotFound = errors.New("事件不存在")
|
||||
|
||||
type EventResults struct {
|
||||
Evaluations []Evaluation `json:"evaluations"`
|
||||
Alerts []AlertLink `json:"alerts"`
|
||||
}
|
||||
|
||||
type AlertLink struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) ForEvent(ctx context.Context, eventID string) (EventResults, error) {
|
||||
if _, err := uuid.Parse(eventID); err != nil {
|
||||
return EventResults{}, ErrEventNotFound
|
||||
}
|
||||
var count int64
|
||||
if err := s.DB.WithContext(ctx).Table("bell_events").Where("id = ?", eventID).Count(&count).Error; err != nil {
|
||||
return EventResults{}, err
|
||||
}
|
||||
if count == 0 {
|
||||
return EventResults{}, ErrEventNotFound
|
||||
}
|
||||
result := EventResults{Evaluations: make([]Evaluation, 0), Alerts: make([]AlertLink, 0)}
|
||||
if err := s.DB.WithContext(ctx).Where("event_id = ?", eventID).Order("evaluated_at, rule_id").Find(&result.Evaluations).Error; err != nil {
|
||||
return EventResults{}, err
|
||||
}
|
||||
err := s.DB.WithContext(ctx).Table("bell_alerts a").
|
||||
Select("a.id, a.summary, a.status, a.severity, a.location").
|
||||
Joins("JOIN bell_alert_events ae ON ae.alert_id = a.id").
|
||||
Where("ae.event_id = ?", eventID).Order("a.created_at, a.id").Scan(&result.Alerts).Error
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package event
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("事件字段无效")
|
||||
ErrConflict = errors.New("幂等键已用于不同事件载荷")
|
||||
ErrNotFound = errors.New("事件不存在")
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is an immutable Bell business fact. It intentionally does not embed
|
||||
// GoAdmin's mutable/soft-delete model fields.
|
||||
type Event struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_event_key"`
|
||||
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_event_key"`
|
||||
EventType string `json:"eventType" gorm:"size:128;not null;index"`
|
||||
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
|
||||
Location string `json:"location" gorm:"size:256;not null;index"`
|
||||
Severity string `json:"severity" gorm:"size:16;not null;index"`
|
||||
EvidenceRef *string `json:"evidenceRef,omitempty" gorm:"size:512"`
|
||||
NormalizedPayload json.RawMessage `json:"normalizedPayload" gorm:"type:jsonb;not null"`
|
||||
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
|
||||
ReceivedAt time.Time `json:"receivedAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (Event) TableName() string { return "bell_events" }
|
||||
@@ -0,0 +1,105 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProducerID = 128
|
||||
maxSourceEventID = 256
|
||||
maxEventType = 128
|
||||
maxLocation = 256
|
||||
maxEvidenceRef = 512
|
||||
maxAttributes = 48 * 1024
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
ProducerID string `json:"producerId"`
|
||||
SourceEventID string `json:"sourceEventId"`
|
||||
EventType string `json:"eventType"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidenceRef,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
type Normalized struct {
|
||||
Command Command
|
||||
Payload []byte
|
||||
Digest string
|
||||
}
|
||||
|
||||
func Normalize(command Command) (Normalized, error) {
|
||||
command.ProducerID = strings.TrimSpace(command.ProducerID)
|
||||
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
|
||||
command.EventType = strings.TrimSpace(command.EventType)
|
||||
command.Location = strings.TrimSpace(command.Location)
|
||||
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
|
||||
command.OccurredAt = command.OccurredAt.UTC()
|
||||
|
||||
if !validText(command.ProducerID, maxProducerID) ||
|
||||
!validText(command.SourceEventID, maxSourceEventID) ||
|
||||
!validText(command.EventType, maxEventType) ||
|
||||
!validText(command.Location, maxLocation) || command.OccurredAt.IsZero() {
|
||||
return Normalized{}, ErrInvalid
|
||||
}
|
||||
switch command.Severity {
|
||||
case "low", "medium", "high", "critical":
|
||||
default:
|
||||
return Normalized{}, ErrInvalid
|
||||
}
|
||||
if command.EvidenceRef != nil {
|
||||
value := strings.TrimSpace(*command.EvidenceRef)
|
||||
lower := strings.ToLower(value)
|
||||
if !validText(value, maxEvidenceRef) || strings.Contains(value, "@") ||
|
||||
strings.Contains(value, "\\") || strings.HasPrefix(lower, "file:") {
|
||||
return Normalized{}, fmt.Errorf("%w: evidenceRef 必须是安全的逻辑引用", ErrInvalid)
|
||||
}
|
||||
command.EvidenceRef = &value
|
||||
}
|
||||
if command.Attributes == nil {
|
||||
command.Attributes = map[string]any{}
|
||||
}
|
||||
attributes, err := canonicalJSON(command.Attributes)
|
||||
if err != nil || len(attributes) > maxAttributes {
|
||||
return Normalized{}, fmt.Errorf("%w: attributes 无效或过大", ErrInvalid)
|
||||
}
|
||||
// Round trip the attributes so map ordering and nested values have one
|
||||
// deterministic representation before the complete command is hashed.
|
||||
if err = json.Unmarshal(attributes, &command.Attributes); err != nil {
|
||||
return Normalized{}, fmt.Errorf("%w: attributes 无效", ErrInvalid)
|
||||
}
|
||||
payload, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return Normalized{}, fmt.Errorf("normalize event: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
return Normalized{Command: command, Payload: payload, Digest: hex.EncodeToString(digest[:])}, nil
|
||||
}
|
||||
|
||||
func canonicalJSON(value any) ([]byte, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var normalized any
|
||||
if err = decoder.Decode(&normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(normalized)
|
||||
}
|
||||
|
||||
func validText(value string, max int) bool {
|
||||
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
|
||||
!strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Event Event `json:"event"`
|
||||
Receipt receipt.Receipt `json:"receipt"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, command Command, actorID int) (Result, error) {
|
||||
if s.DB == nil {
|
||||
return Result{}, errors.New("event database is unavailable")
|
||||
}
|
||||
normalized, err := Normalize(command)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var result Result
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockKey := fmt.Sprintf("%d:%s:%s", len(normalized.Command.ProducerID), normalized.Command.ProducerID, normalized.Command.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", lockKey).Error; err != nil {
|
||||
return fmt.Errorf("lock event idempotency key: %w", err)
|
||||
}
|
||||
|
||||
var existing receipt.Receipt
|
||||
err := tx.Where("producer_id = ? AND source_event_id = ?", normalized.Command.ProducerID, normalized.Command.SourceEventID).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != normalized.Digest {
|
||||
return ErrConflict
|
||||
}
|
||||
if err = tx.First(&result.Event, "id = ?", existing.EventID).Error; err != nil {
|
||||
return fmt.Errorf("load replay event: %w", err)
|
||||
}
|
||||
result.Receipt = existing
|
||||
result.Duplicate = true
|
||||
return tx.Create(&receipt.IngestAudit{
|
||||
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
|
||||
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeReplay, ActorID: actorID, CreatedAt: time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("load event receipt: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
result.Event = Event{
|
||||
ID: uuid.NewString(), ProducerID: normalized.Command.ProducerID,
|
||||
SourceEventID: normalized.Command.SourceEventID, EventType: normalized.Command.EventType,
|
||||
OccurredAt: normalized.Command.OccurredAt, Location: normalized.Command.Location,
|
||||
Severity: normalized.Command.Severity, EvidenceRef: normalized.Command.EvidenceRef,
|
||||
NormalizedPayload: normalized.Payload, PayloadSHA256: normalized.Digest, ReceivedAt: now,
|
||||
}
|
||||
result.Receipt = receipt.Receipt{
|
||||
ID: uuid.NewString(), EventID: result.Event.ID, ProducerID: result.Event.ProducerID,
|
||||
SourceEventID: result.Event.SourceEventID, PayloadSHA256: normalized.Digest, AcceptedAt: now,
|
||||
}
|
||||
if err = tx.Create(&result.Event).Error; err != nil {
|
||||
return fmt.Errorf("create event: %w", err)
|
||||
}
|
||||
if err = tx.Create(&result.Receipt).Error; err != nil {
|
||||
return fmt.Errorf("create event receipt: %w", err)
|
||||
}
|
||||
return tx.Create(&receipt.IngestAudit{
|
||||
ProducerID: result.Event.ProducerID, SourceEventID: result.Event.SourceEventID,
|
||||
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeAccepted, ActorID: actorID, CreatedAt: now,
|
||||
}).Error
|
||||
})
|
||||
if errors.Is(err, ErrConflict) {
|
||||
// The conflict audit must commit independently from the rejected ingest.
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{
|
||||
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
|
||||
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeConflict, ActorID: actorID, CreatedAt: time.Now().UTC(),
|
||||
}).Error
|
||||
if auditErr != nil {
|
||||
return Result{}, fmt.Errorf("record idempotency conflict audit: %w", auditErr)
|
||||
}
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s Service) Get(ctx context.Context, id string) (Event, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Event{}, ErrNotFound
|
||||
}
|
||||
var item Event
|
||||
if err := s.DB.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Event{}, ErrNotFound
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
type EvidenceClient struct {
|
||||
Endpoint string
|
||||
Signer machine_identity.Signer
|
||||
HTTP interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
|
||||
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
|
||||
path := "/v1/evidence/" + status.EvidenceID
|
||||
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "machine_identity_error")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", newCorrelationID())
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
code := "evidence_unavailable"
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = "evidence_timeout"
|
||||
}
|
||||
return c.degrade(db, status, "unavailable", code)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if readErr != nil || len(body) > 64*1024 {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return c.degrade(db, status, "unavailable", "evidence_not_found")
|
||||
}
|
||||
if response.StatusCode == http.StatusGone {
|
||||
return c.degrade(db, status, "expired", "evidence_expired")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return c.degrade(db, status, "unavailable", "evidence_unavailable")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var evidence Evidence
|
||||
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
canonical, err := canonicalJSON(body)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func newCorrelationID() string {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "request-id-fallback"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
|
||||
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Bell evidence key: %w", err)
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
|
||||
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
const MaxRequestBytes = 64 * 1024
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
Enabled bool
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (h Handler) Post(c *gin.Context) {
|
||||
if !h.Enabled {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
|
||||
return
|
||||
}
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
} else if !requestIDPattern.MatchString(requestID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
|
||||
return
|
||||
}
|
||||
c.Header("X-Request-ID", requestID)
|
||||
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
|
||||
return
|
||||
}
|
||||
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
|
||||
relayID := strings.TrimSpace(relayHeader)
|
||||
if relayID != relayHeader || !validID(relayID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
code := machineErrorCode(err)
|
||||
if code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeProblem(c, status, code, "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
parsed, err := ParseEvent(body)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnsupportedSchema) {
|
||||
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
|
||||
return
|
||||
}
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
|
||||
return
|
||||
}
|
||||
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if result.Disposition == "duplicate" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, result)
|
||||
}
|
||||
|
||||
func writeProblem(c *gin.Context, status int, code, message, existing string) {
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
|
||||
}
|
||||
|
||||
func machineErrorCode(err error) string {
|
||||
var machineErr *machine_identity.Error
|
||||
if errors.As(err, &machineErr) {
|
||||
return machineErr.Code
|
||||
}
|
||||
return "machine_token_invalid"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EventSchemaVersion = "yovision.event/v1"
|
||||
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
SiteRef string `json:"site_ref"`
|
||||
DeviceRef string `json:"device_ref"`
|
||||
ProfileRef string `json:"profile_ref"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Severity string `json:"severity"`
|
||||
Rule Rule `json:"rule"`
|
||||
Model Model `json:"model"`
|
||||
Observation Observation `json:"observation"`
|
||||
Region Region `json:"region"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
TrackID string `json:"track_id"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
|
||||
}
|
||||
|
||||
type Region struct {
|
||||
RegionID string `json:"region_id"`
|
||||
Kind string `json:"kind"`
|
||||
CrossingDirection string `json:"crossing_direction,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CapturedAt string `json:"captured_at"`
|
||||
StatusUpdatedAt string `json:"status_updated_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
|
||||
Failure *EvidenceFailure `json:"failure,omitempty"`
|
||||
}
|
||||
|
||||
type EvidenceIntegrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Digest string `json:"digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type EvidenceFailure struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ParsedEvent struct {
|
||||
Event Event
|
||||
Canonical json.RawMessage
|
||||
Digest string
|
||||
Occurred time.Time
|
||||
}
|
||||
|
||||
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
|
||||
// from the immutable Event and from Alert acknowledgement/close facts.
|
||||
type EvidenceStatus struct {
|
||||
EventID string `gorm:"type:uuid;primaryKey"`
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null"`
|
||||
Resolution string `gorm:"size:16;not null;index"`
|
||||
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
|
||||
LastError string `gorm:"size:64;not null;default:''"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
CheckedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
|
||||
@@ -0,0 +1,42 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReplayToken is Bell-owned security state. It is intentionally separate from
|
||||
// business Receipt idempotency and remains effective across process restarts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
|
||||
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
func RegisterRuntime(engine *gin.Engine) error {
|
||||
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
db := sdk.Runtime.GetDbByKey("")
|
||||
if db == nil {
|
||||
return fmt.Errorf("Bell event ingress database is unavailable")
|
||||
}
|
||||
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
|
||||
return fmt.Errorf("Bell event ingress migration is required")
|
||||
}
|
||||
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell machine identity registry: %w", err)
|
||||
}
|
||||
var resolver EvidenceRefresher
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
|
||||
resolver, err = LoadEvidenceClient(os.Getenv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell evidence resolver: %w", err)
|
||||
}
|
||||
}
|
||||
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
|
||||
engine.POST("/v1/events", handler.Post)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
|
||||
|
||||
type EvidenceRefresher interface {
|
||||
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
|
||||
if s.DB == nil {
|
||||
return IngestResult{}, errors.New("event database is unavailable")
|
||||
}
|
||||
var output IngestResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var existing struct {
|
||||
EventID string
|
||||
PayloadSHA256 string
|
||||
}
|
||||
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != parsed.Digest {
|
||||
output.EventID = existing.EventID
|
||||
return ErrIdempotencyConflict
|
||||
}
|
||||
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
|
||||
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
eventID := uuid.NewString()
|
||||
var evidenceRef *string
|
||||
if len(parsed.Event.Evidence) > 0 {
|
||||
value := parsed.Event.Evidence[0].EvidenceID
|
||||
evidenceRef = &value
|
||||
}
|
||||
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
|
||||
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
|
||||
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
|
||||
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&receiptItem).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, evidence := range parsed.Event.Evidence {
|
||||
payload, marshalErr := json.Marshal(evidence)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
canonical, canonicalErr := canonicalJSON(payload)
|
||||
if canonicalErr != nil {
|
||||
return canonicalErr
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
|
||||
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
|
||||
if err := tx.Create(&status).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
if auditErr != nil {
|
||||
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
|
||||
}
|
||||
return output, ErrIdempotencyConflict
|
||||
}
|
||||
if err != nil || s.Resolver == nil {
|
||||
return output, err
|
||||
}
|
||||
var statuses []EvidenceStatus
|
||||
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
for _, status := range statuses {
|
||||
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
|
||||
// remains accepted even when Sense is unavailable.
|
||||
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEvent = errors.New("invalid_event")
|
||||
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
func ParseEvent(raw []byte) (ParsedEvent, error) {
|
||||
var event Event
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
|
||||
}
|
||||
if event.SchemaVersion == "" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.SchemaVersion != EventSchemaVersion {
|
||||
return ParsedEvent{}, ErrUnsupportedSchema
|
||||
}
|
||||
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
|
||||
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
|
||||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
|
||||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
|
||||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
|
||||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
|
||||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
for _, value := range event.Observation.BBoxNormalized {
|
||||
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
|
||||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Evidence == nil || len(event.Evidence) > 8 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence := map[string]bool{}
|
||||
for _, evidence := range event.Evidence {
|
||||
evidenceJSON, marshalErr := json.Marshal(evidence)
|
||||
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
|
||||
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence[string(canonicalEvidence)] = true
|
||||
}
|
||||
canonical, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if containsExplicitNull(raw) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
|
||||
}
|
||||
|
||||
func validateEvidence(value Evidence) error {
|
||||
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
|
||||
(value.Type != "snapshot" && value.Type != "clip") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if value.ExpiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
switch value.Status {
|
||||
case "pending", "processing":
|
||||
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "success":
|
||||
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
|
||||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "failed":
|
||||
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
|
||||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
default:
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func containsExplicitNull(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if decoder.Decode(&value) != nil {
|
||||
return true
|
||||
}
|
||||
return hasNull(value)
|
||||
}
|
||||
|
||||
func hasNull(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validID(value string) bool {
|
||||
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryDocument struct {
|
||||
Version string `json:"version"`
|
||||
Audience string `json:"audience"`
|
||||
Principals []registryPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type registryPrincipal struct {
|
||||
PrincipalID string `json:"principal_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Keys []registryKey `json:"keys"`
|
||||
}
|
||||
|
||||
type registryKey struct {
|
||||
KeyID string `json:"kid"`
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
|
||||
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
|
||||
return nil, errors.New("machine principal registry path and audience are required")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine principal registry")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document registryDocument
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records := make([]KeyRecord, 0)
|
||||
for _, principal := range document.Principals {
|
||||
if len(principal.Keys) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
for _, key := range principal.Keys {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
|
||||
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
|
||||
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
|
||||
}
|
||||
}
|
||||
return NewRegistry(records...)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
|
||||
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
|
||||
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
|
||||
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "yovision.machine-identity/v1"
|
||||
TokenType = "YOVISION-MACHINE+JWT"
|
||||
MaxLifetime = 5 * time.Minute
|
||||
AllowedSkew = 30 * time.Second
|
||||
MaxKeyOverlap = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Error struct{ Code string }
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
|
||||
func codeError(code string) error { return &Error{Code: code} }
|
||||
|
||||
// BearerToken deliberately has no cookie or query fallback.
|
||||
func BearerToken(authorization string) (string, error) {
|
||||
parts := strings.Split(authorization, " ")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
|
||||
return "", codeError("machine_token_missing")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version string `json:"ver"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scopes []string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
Method string `json:"htm"`
|
||||
Path string `json:"htu"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
}
|
||||
|
||||
type protectedHeader struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version string `json:"ver"`
|
||||
}
|
||||
|
||||
type KeyRecord struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PublicKey ed25519.PublicKey
|
||||
Audience string
|
||||
Scopes []string
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]KeyRecord
|
||||
}
|
||||
|
||||
func NewRegistry(records ...KeyRecord) (*Registry, error) {
|
||||
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
|
||||
for _, record := range records {
|
||||
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
|
||||
return nil, errors.New("invalid machine key record")
|
||||
}
|
||||
if _, exists := r.keys[record.KeyID]; exists {
|
||||
return nil, errors.New("duplicate machine key id")
|
||||
}
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
r.keys[record.KeyID] = record
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
record, ok := r.keys[keyID]
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(keyID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.keys[keyID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
record.Revoked = true
|
||||
r.keys[keyID] = record
|
||||
return true
|
||||
}
|
||||
|
||||
type ReplayStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
}
|
||||
|
||||
// ReplayCache must atomically persist accepted (principal, jti) pairs until
|
||||
// expiry. ReplayStore is process-local and intended for tests or a single
|
||||
// uninterrupted process; connector implementations inject a durable store.
|
||||
type ReplayCache interface {
|
||||
Consume(principal, tokenID string, expiresAt, now time.Time) bool
|
||||
}
|
||||
|
||||
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
|
||||
|
||||
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiry := range s.used {
|
||||
if !expiry.After(now) {
|
||||
delete(s.used, key)
|
||||
}
|
||||
}
|
||||
key := principal + "\x00" + tokenID
|
||||
if _, exists := s.used[key]; exists {
|
||||
return false
|
||||
}
|
||||
s.used[key] = expiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("machine private key path is required")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine private key")
|
||||
}
|
||||
block, rest := pem.Decode(raw)
|
||||
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
|
||||
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse machine private key")
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("machine private key is not Ed25519")
|
||||
}
|
||||
return slices.Clone(key), nil
|
||||
}
|
||||
|
||||
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
|
||||
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
|
||||
return "", errors.New("incomplete machine signer configuration")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
if !allowedMethod(method) {
|
||||
return "", errors.New("unsupported machine request method")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
|
||||
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
|
||||
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
|
||||
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
|
||||
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
|
||||
return signingInput + "." + rawBase64(signature), nil
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
Registry *Registry
|
||||
Replay ReplayCache
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
|
||||
if v.Registry == nil || v.Replay == nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || strings.Contains(token, "=") {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
headerBytes, err := decodeRaw(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var header protectedHeader
|
||||
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
record, ok := v.Registry.Lookup(header.KeyID)
|
||||
if !ok {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
signature, err := decodeRaw(parts[2])
|
||||
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !record.Enabled || record.Revoked {
|
||||
return Claims{}, codeError("machine_identity_revoked")
|
||||
}
|
||||
claimsBytes, err := decodeRaw(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var claims Claims
|
||||
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if v.Now != nil {
|
||||
now = v.Now().UTC()
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
|
||||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_expired")
|
||||
}
|
||||
if claims.Audience != audience || record.Audience != audience {
|
||||
return Claims{}, codeError("machine_audience_denied")
|
||||
}
|
||||
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
|
||||
return Claims{}, codeError("machine_scope_denied")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
|
||||
return Claims{}, codeError("machine_token_replayed")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeClosed(raw []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validClaimsShape(claims Claims) bool {
|
||||
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
|
||||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
|
||||
return false
|
||||
}
|
||||
if !validScopeList(claims.Scopes) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(claims.BodySHA256)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScopeList(scopes []string) bool {
|
||||
if len(scopes) == 0 || len(scopes) > 4 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, scope := range scopes {
|
||||
if !validScopes[scope] || seen[scope] {
|
||||
return false
|
||||
}
|
||||
seen[scope] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
|
||||
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
|
||||
}
|
||||
return parsed.EscapedPath(), nil
|
||||
}
|
||||
|
||||
func allowedMethod(method string) bool {
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate machine token id: %w", err)
|
||||
}
|
||||
return rawBase64(raw), nil
|
||||
}
|
||||
|
||||
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
|
||||
|
||||
func decodeRaw(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type crossLanguageVector struct {
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Token string `json:"token"`
|
||||
Now int64 `json:"now"`
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"required_scope"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body_base64"`
|
||||
}
|
||||
|
||||
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
||||
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var coded *Error
|
||||
if !errors.As(err, &coded) {
|
||||
t.Fatalf("expected coded error, got %v", err)
|
||||
}
|
||||
return coded.Code
|
||||
}
|
||||
|
||||
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
||||
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
||||
t.Fatalf("accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
mint := func() string {
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
token := mint()
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryRevocationAndRotation(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
||||
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rotation, err := NewRegistry(
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
||||
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
||||
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotation.Revoke("brain-old-0001") {
|
||||
t.Fatal("old key was not revoked")
|
||||
}
|
||||
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
||||
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
||||
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
||||
if err := safe.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := safe
|
||||
unsafe.VerifyHostname = false
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("unsafe hostname policy accepted")
|
||||
}
|
||||
unsafe = safe
|
||||
unsafe.TLSMinVersion = tls.VersionTLS11
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("TLS 1.1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
||||
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
||||
raw, err := os.ReadFile(vectorPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var vector crossLanguageVector
|
||||
if err = json.Unmarshal(raw, &vector); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
||||
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != "yv:brain:vector" {
|
||||
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
||||
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
||||
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
||||
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
||||
}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
file := filepath.Join(t.TempDir(), "principals.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := LoadRegistry(file, "yovision-bell")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
||||
t.Fatal("registry record missing")
|
||||
}
|
||||
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
||||
t.Fatal("wrong registry audience accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TransportPolicy struct {
|
||||
TLSMinVersion uint16
|
||||
VerifyCertificate bool
|
||||
VerifyHostname bool
|
||||
ConnectTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func (p TransportPolicy) Validate() error {
|
||||
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
|
||||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
|
||||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
|
||||
return errors.New("machine transport policy is unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
|
||||
TLSHandshakeTimeout: p.ConnectTimeout,
|
||||
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package receipt
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
OutcomeAccepted = "accepted"
|
||||
OutcomeReplay = "replay"
|
||||
OutcomeConflict = "conflict"
|
||||
)
|
||||
|
||||
// IngestAudit contains only identifiers, a digest and an outcome. Event payloads,
|
||||
// credentials and tokens must never be copied into this append-only audit table.
|
||||
type IngestAudit struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProducerID string `json:"producerId" gorm:"size:128;not null;index"`
|
||||
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;index"`
|
||||
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
|
||||
Outcome string `json:"outcome" gorm:"size:16;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (IngestAudit) TableName() string { return "bell_event_ingest_audits" }
|
||||
@@ -0,0 +1,16 @@
|
||||
package receipt
|
||||
|
||||
import "time"
|
||||
|
||||
// Receipt permanently binds an idempotency key to the accepted Event payload.
|
||||
// It deliberately has no UpdatedAt or soft-delete fields because it is immutable.
|
||||
type Receipt struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
EventID string `json:"eventId" gorm:"type:uuid;not null;uniqueIndex"`
|
||||
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_receipt_key"`
|
||||
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_receipt_key"`
|
||||
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
|
||||
AcceptedAt time.Time `json:"acceptedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Receipt) TableName() string { return "bell_event_receipts" }
|
||||
@@ -0,0 +1,21 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerAlertLifecycleRouter) }
|
||||
|
||||
func registerAlertLifecycleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
handler := alert_lifecycle.Handler{}
|
||||
routes := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
routes.GET("/alerts/:id/lifecycle", handler.Get)
|
||||
routes.POST("/alerts/:id/ack", handler.Ack)
|
||||
routes.POST("/alerts/:id/close", handler.Close)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerContactRouter) }
|
||||
func registerContactRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
h := contact.Handler{}
|
||||
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
secured.GET("/contacts", h.List)
|
||||
secured.POST("/contacts", h.Create)
|
||||
secured.PUT("/contacts/:id", h.Update)
|
||||
secured.PUT("/contacts/:id/enabled", h.SetEnabled)
|
||||
secured.POST("/contacts/:id/channels", h.AddChannel)
|
||||
secured.POST("/contact-channels/:id/validations", h.ValidateChannel)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/duty_schedule"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerDutyScheduleRouter) }
|
||||
func registerDutyScheduleRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
h := duty_schedule.Handler{}
|
||||
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
secured.GET("/duty-groups", h.List)
|
||||
secured.POST("/duty-groups", h.CreateGroup)
|
||||
secured.PUT("/duty-groups/:id", h.UpdateGroup)
|
||||
secured.POST("/duty-groups/:id/members", h.AddMember)
|
||||
secured.POST("/duty-groups/:id/schedules", h.CreateSchedule)
|
||||
secured.POST("/duty-schedules/:id/publish", h.Publish)
|
||||
secured.POST("/duty-groups/:id/overrides", h.CreateOverride)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
type eventHandler struct{ api.Api }
|
||||
|
||||
func registerEventRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
handler := eventHandler{}
|
||||
v1.GET("/events/:id", authMiddleware.MiddlewareFunc(), handler.Get)
|
||||
}
|
||||
|
||||
func (h eventHandler) Get(c *gin.Context) {
|
||||
h.MakeContext(c).MakeOrm()
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
|
||||
return
|
||||
}
|
||||
item, err := event.NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, event.ErrNotFound) {
|
||||
h.Error(http.StatusNotFound, event.ErrNotFound, event.ErrNotFound.Error())
|
||||
return
|
||||
}
|
||||
h.Logger.Errorf("load Bell event failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
|
||||
return
|
||||
}
|
||||
h.OK(item, "查询成功")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
type registrar func(*gin.RouterGroup, *jwt.GinJWTMiddleware)
|
||||
|
||||
var registrars = []registrar{registerEventRouter}
|
||||
|
||||
func InitRouter() {
|
||||
engine, ok := sdk.Runtime.GetEngine().(*gin.Engine)
|
||||
if !ok || engine == nil {
|
||||
log.Error("Bell business router requires Gin engine")
|
||||
return
|
||||
}
|
||||
authMiddleware, err := middleware.AuthInit()
|
||||
if err != nil {
|
||||
log.Errorf("Bell business JWT init error: %v", err)
|
||||
return
|
||||
}
|
||||
v1 := engine.Group("/api/v1/bell")
|
||||
for _, register := range registrars {
|
||||
register(v1, authMiddleware)
|
||||
}
|
||||
if err := event_ingress.RegisterRuntime(engine); err != nil {
|
||||
log.Errorf("Bell event ingress init error: %v", err)
|
||||
}
|
||||
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
|
||||
registerSyntheticRouter(v1, authMiddleware)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/rule"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registrars = append(registrars, registerRuleAlertRouter)
|
||||
}
|
||||
|
||||
func registerRuleAlertRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
rules := rule.Handler{}
|
||||
alerts := alert.Handler{}
|
||||
secured := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
secured.GET("/rules", rules.List)
|
||||
secured.POST("/rules", rules.Create)
|
||||
secured.PUT("/rules/:id", rules.Update)
|
||||
secured.PUT("/rules/:id/enabled", rules.SetEnabled)
|
||||
|
||||
secured.GET("/alerts", alerts.List)
|
||||
secured.GET("/alerts/:id", alerts.Get)
|
||||
secured.GET("/events", alerts.ListEvents)
|
||||
secured.GET("/events/:id/rule-results", alerts.EventResults)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/synthetic"
|
||||
)
|
||||
|
||||
func registerSyntheticRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
handler := synthetic.Handler{}
|
||||
v1.POST("/synthetic-events", authMiddleware.MiddlewareFunc(), handler.Create)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
type enabledInput struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var query PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, "查询条件不正确")
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.Logger.Errorf("list Bell rules failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("读取规则失败"), "读取规则失败")
|
||||
return
|
||||
}
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
h.PageOK(items, int(count), page, size, "查询成功")
|
||||
}
|
||||
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !isAdmin(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Create(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.writeResult(item, err)
|
||||
}
|
||||
|
||||
func (h Handler) Update(c *gin.Context) {
|
||||
if !isAdmin(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.writeResult(item, err)
|
||||
}
|
||||
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
if !isAdmin(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
|
||||
return
|
||||
}
|
||||
var input enabledInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil || input.Enabled == nil {
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, user.GetUserId(c))
|
||||
h.writeResult(item, err)
|
||||
}
|
||||
|
||||
func (h Handler) writeResult(item Rule, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid):
|
||||
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell rule failed: %v", err)
|
||||
h.Error(http.StatusConflict, errors.New("规则编码已存在或保存失败"), "规则编码已存在或保存失败")
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package rule
|
||||
|
||||
import "time"
|
||||
|
||||
// Rule is the current editable rule definition. Historical evaluations keep a
|
||||
// complete versioned snapshot, so editing this row never rewrites history.
|
||||
type Rule struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Code string `json:"code" gorm:"size:128;not null;uniqueIndex"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
EventType *string `json:"eventType,omitempty" gorm:"size:128"`
|
||||
MinimumSeverity string `json:"minimumSeverity" gorm:"size:16;not null"`
|
||||
LocationContains *string `json:"locationContains,omitempty" gorm:"size:128"`
|
||||
Version int `json:"version" gorm:"not null;default:1"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
UpdatedBy int `json:"updatedBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Rule) TableName() string { return "bell_rules" }
|
||||
@@ -0,0 +1,124 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Name string `form:"name"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]Rule, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Rule{})
|
||||
if name := strings.TrimSpace(query.Name); name != "" {
|
||||
db = db.Where("name ILIKE ? OR code ILIKE ?", "%"+name+"%", "%"+name+"%")
|
||||
}
|
||||
if query.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *query.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]Rule, 0)
|
||||
err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||
return items, count, err
|
||||
}
|
||||
|
||||
func (s Service) Create(ctx context.Context, input WriteInput, actorID int) (Rule, error) {
|
||||
normalized, err := Normalize(input, true)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Rule{
|
||||
ID: uuid.NewString(), Code: normalized.Code, Name: normalized.Name, Enabled: true,
|
||||
EventType: normalized.EventType, MinimumSeverity: normalized.MinimumSeverity,
|
||||
LocationContains: normalized.LocationContains, Version: 1,
|
||||
CreatedBy: actorID, UpdatedBy: actorID, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err = s.DB.WithContext(ctx).Create(&item).Error; err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s Service) Update(ctx context.Context, id string, input WriteInput, actorID int) (Rule, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
normalized, err := Normalize(input, false)
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
var item Rule
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": normalized.Name, "event_type": normalized.EventType,
|
||||
"minimum_severity": normalized.MinimumSeverity, "location_contains": normalized.LocationContains,
|
||||
"version": item.Version + 1, "updated_by": actorID, "updated_at": time.Now().UTC(),
|
||||
}
|
||||
if err := tx.Model(&item).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&item, "id = ?", id).Error
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, actorID int) (Rule, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
var item Rule
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if item.Enabled == enabled {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Model(&item).Updates(map[string]any{
|
||||
"enabled": enabled, "version": item.Version + 1,
|
||||
"updated_by": actorID, "updated_at": time.Now().UTC(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&item, "id = ?", id).Error
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
|
||||
func pageValues(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("规则内容不符合要求")
|
||||
ErrNotFound = errors.New("规则不存在")
|
||||
codePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,127}$`)
|
||||
)
|
||||
|
||||
type WriteInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType *string `json:"eventType"`
|
||||
MinimumSeverity string `json:"minimumSeverity"`
|
||||
LocationContains *string `json:"locationContains"`
|
||||
}
|
||||
|
||||
func Normalize(input WriteInput, requireCode bool) (WriteInput, error) {
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
|
||||
if requireCode && !codePattern.MatchString(input.Code) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
if !validText(input.Name, 128) || !validSeverity(input.MinimumSeverity) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
var err error
|
||||
if input.EventType, err = optionalText(input.EventType, 128); err != nil {
|
||||
return WriteInput{}, err
|
||||
}
|
||||
if input.LocationContains, err = optionalText(input.LocationContains, 128); err != nil {
|
||||
return WriteInput{}, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func validSeverity(value string) bool {
|
||||
switch value {
|
||||
case "low", "medium", "high", "critical":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func optionalText(value *string, max int) (*string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
normalized := strings.TrimSpace(*value)
|
||||
if normalized == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !validText(normalized, max) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func validText(value string, max int) bool {
|
||||
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
|
||||
!strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const EnabledEnv = "BELL_SYNTHETIC_EVENTS_ENABLED"
|
||||
|
||||
// Enabled requires both a non-production runtime and an explicit opt-in.
|
||||
func Enabled(mode string, getenv func(string) string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(mode), "prod") || strings.EqualFold(strings.TrimSpace(mode), "production") {
|
||||
return false
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(getenv(EnabledEnv)))
|
||||
return value == "true" || value == "1"
|
||||
}
|
||||
|
||||
func EnabledFromEnvironment(mode string) bool { return Enabled(mode, os.Getenv) }
|
||||
@@ -0,0 +1,58 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
const maxRequestBytes = 64 * 1024
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func IsAdministrator(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !IsAdministrator(c) {
|
||||
h.MakeContext(c).Error(http.StatusForbidden, errors.New("无权使用合成事件入口"), "无权使用合成事件入口")
|
||||
return
|
||||
}
|
||||
if err := restoreRequestBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
var request CreateRequest
|
||||
h.MakeContext(c).MakeOrm().Bind(&request, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
result, err := event.NewService(h.Orm).Ingest(c.Request.Context(), request.Command(), user.GetUserId(c))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, event.ErrInvalid):
|
||||
h.Error(http.StatusBadRequest, event.ErrInvalid, event.ErrInvalid.Error())
|
||||
case errors.Is(err, event.ErrConflict):
|
||||
h.Error(http.StatusConflict, event.ErrConflict, event.ErrConflict.Error())
|
||||
default:
|
||||
h.Logger.Errorf("synthetic event ingest failed: %v", err)
|
||||
h.Error(http.StatusInternalServerError, errors.New("合成事件写入失败"), "合成事件写入失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
h.OK(result, "合成事件已接收")
|
||||
// The client already received the full response. Keep only a fixed marker
|
||||
// for GoAdmin's generic operation audit, which runs after the handler.
|
||||
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
requestBodyKey = "bell.synthetic.request-body"
|
||||
requestBodyErrorKey = "bell.synthetic.request-body-error"
|
||||
)
|
||||
|
||||
var redactedRequestBody = []byte(`{"redacted":true}`)
|
||||
|
||||
// RedactRequestBody runs before GoAdmin's operation logger. It keeps the real
|
||||
// body only in the request context for the handler and exposes a fixed marker
|
||||
// to the generic audit middleware so event payloads never enter sys_opera_log.
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || c.Request.URL.Path != "/api/v1/bell/synthetic-events" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(requestBodyErrorKey, err)
|
||||
} else if len(body) > maxRequestBytes {
|
||||
c.Set(requestBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(requestBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(redactedRequestBody))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreRequestBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(requestBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(requestBodyKey)
|
||||
if !ok {
|
||||
return errors.New("synthetic request body was not captured")
|
||||
}
|
||||
body := value.([]byte)
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
const ProducerID = "bell.synthetic"
|
||||
|
||||
type CreateRequest struct {
|
||||
SourceEventID string `json:"sourceEventId" binding:"required"`
|
||||
EventType string `json:"eventType" binding:"required"`
|
||||
OccurredAt time.Time `json:"occurredAt" binding:"required"`
|
||||
Location string `json:"location" binding:"required"`
|
||||
Severity string `json:"severity" binding:"required"`
|
||||
EvidenceRef *string `json:"evidenceRef,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
func (r CreateRequest) Command() event.Command {
|
||||
return event.Command{
|
||||
ProducerID: ProducerID, SourceEventID: r.SourceEventID, EventType: r.EventType,
|
||||
OccurredAt: r.OccurredAt, Location: r.Location, Severity: r.Severity,
|
||||
EvidenceRef: r.EvidenceRef, Attributes: r.Attributes,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ import (
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/router"
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/app/bell/contact"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/bellconfig"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/global"
|
||||
@@ -54,6 +58,7 @@ func init() {
|
||||
|
||||
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
AppRouters = append(AppRouters, bellrouter.InitRouter)
|
||||
}
|
||||
|
||||
func setup() error {
|
||||
@@ -178,7 +183,10 @@ func initRouter() {
|
||||
//r.Use(middleware.Metrics())
|
||||
r.Use(common.Sentinel()).
|
||||
Use(common.RequestId(pkg.TrafficKey)).
|
||||
Use(api.SetRequestLogger)
|
||||
Use(api.SetRequestLogger).
|
||||
Use(synthetic.RedactRequestBody()).
|
||||
Use(alert_lifecycle.RedactRequestBody()).
|
||||
Use(contact.RedactRequestBody())
|
||||
|
||||
common.InitMiddleware(r)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventReceipt)
|
||||
}
|
||||
|
||||
func migrateBellEventReceipt(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(event.Event), new(receipt.Receipt), new(receipt.IngestAudit)); err != nil {
|
||||
return err
|
||||
}
|
||||
statements := []string{
|
||||
`ALTER TABLE bell_event_receipts ADD CONSTRAINT bell_event_receipts_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_events ADD CONSTRAINT bell_events_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
|
||||
`CREATE OR REPLACE FUNCTION bell_reject_immutable_fact() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Bell immutable fact cannot be changed' USING ERRCODE = '55000'; END $$`,
|
||||
`CREATE TRIGGER bell_events_immutable BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_event_receipts_immutable BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_event_ingest_audits_immutable BEFORE UPDATE OR DELETE ON bell_event_ingest_audits FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/evaluation"
|
||||
"go-admin/app/bell/rule"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellRuleAlert)
|
||||
}
|
||||
|
||||
func migrateBellRuleAlert(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(rule.Rule), new(evaluation.Evaluation), new(alert.Alert), new(alert.AlertEvent), new(alert.RuleMatch), new(runtimeCasbinRule)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range bellRuleAlertSchemaSQL {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedBellRuleAlertAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var bellRuleAlertSchemaSQL = []string{
|
||||
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_severity_check CHECK (minimum_severity IN ('low','medium','high','critical'))`,
|
||||
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_status_check CHECK (status IN ('open','acknowledged','closed'))`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
|
||||
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_rule_fk FOREIGN KEY (primary_rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id, correlation_key) WHERE status = 'open'`,
|
||||
`CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id, alert_id)`,
|
||||
`CREATE TRIGGER bell_rule_evaluations_immutable BEFORE UPDATE OR DELETE ON bell_rule_evaluations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_alert_events_immutable BEFORE UPDATE OR DELETE ON bell_alert_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_rule_matches_immutable BEFORE UPDATE OR DELETE ON bell_rule_matches FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE OR REPLACE FUNCTION bell_evaluate_new_event() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
current_rule bell_rules%ROWTYPE;
|
||||
is_match boolean;
|
||||
reasons text[];
|
||||
explanation_text text;
|
||||
snapshot jsonb;
|
||||
target_alert_id uuid;
|
||||
correlation text;
|
||||
BEGIN
|
||||
FOR current_rule IN SELECT * FROM bell_rules WHERE enabled = true ORDER BY id LOOP
|
||||
reasons := ARRAY[]::text[];
|
||||
IF current_rule.event_type IS NOT NULL AND current_rule.event_type <> NEW.event_type THEN
|
||||
reasons := array_append(reasons, '事件类型不匹配');
|
||||
END IF;
|
||||
IF array_position(ARRAY['low','medium','high','critical'], NEW.severity) <
|
||||
array_position(ARRAY['low','medium','high','critical'], current_rule.minimum_severity) THEN
|
||||
reasons := array_append(reasons, '风险等级低于阈值');
|
||||
END IF;
|
||||
IF current_rule.location_contains IS NOT NULL AND
|
||||
position(lower(current_rule.location_contains) in lower(NEW.location)) = 0 THEN
|
||||
reasons := array_append(reasons, '地点条件不匹配');
|
||||
END IF;
|
||||
is_match := cardinality(reasons) = 0;
|
||||
explanation_text := CASE WHEN is_match THEN '全部条件命中' ELSE array_to_string(reasons, ';') END;
|
||||
snapshot := jsonb_build_object(
|
||||
'id', current_rule.id, 'code', current_rule.code, 'name', current_rule.name,
|
||||
'enabled', current_rule.enabled, 'eventType', current_rule.event_type,
|
||||
'minimumSeverity', current_rule.minimum_severity,
|
||||
'locationContains', current_rule.location_contains, 'version', current_rule.version
|
||||
);
|
||||
INSERT INTO bell_rule_evaluations(event_id, rule_id, rule_version, rule_snapshot, matched, explanation, evaluated_at)
|
||||
VALUES(NEW.id, current_rule.id, current_rule.version, snapshot, is_match, explanation_text, now());
|
||||
IF NOT is_match THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
correlation := lower(trim(NEW.location));
|
||||
INSERT INTO bell_alerts(id, primary_rule_id, correlation_key, status, severity, summary, location, created_at, updated_at)
|
||||
VALUES(gen_random_uuid(), current_rule.id, correlation, 'open', NEW.severity,
|
||||
left(current_rule.name || ':' || NEW.event_type, 256), NEW.location, now(), now())
|
||||
ON CONFLICT(primary_rule_id, correlation_key) WHERE status = 'open'
|
||||
DO UPDATE SET
|
||||
updated_at = now(),
|
||||
severity = CASE
|
||||
WHEN array_position(ARRAY['low','medium','high','critical'], EXCLUDED.severity) >
|
||||
array_position(ARRAY['low','medium','high','critical'], bell_alerts.severity)
|
||||
THEN EXCLUDED.severity ELSE bell_alerts.severity END
|
||||
RETURNING id INTO target_alert_id;
|
||||
INSERT INTO bell_alert_events(alert_id, event_id, linked_at)
|
||||
VALUES(target_alert_id, NEW.id, now());
|
||||
INSERT INTO bell_rule_matches(event_id, rule_id, alert_id, rule_version, rule_snapshot, explanation, matched_at)
|
||||
VALUES(NEW.id, current_rule.id, target_alert_id, current_rule.version, snapshot, explanation_text, now());
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$`,
|
||||
`CREATE TRIGGER bell_events_evaluate_rules AFTER INSERT ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_evaluate_new_event()`,
|
||||
}
|
||||
|
||||
type menuSeed struct {
|
||||
ID int
|
||||
Permission string
|
||||
}
|
||||
|
||||
type apiSeed struct {
|
||||
ID int
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
// runtimeCasbinRule deliberately matches the table used by the frozen
|
||||
// go-admin-core gorm adapter. The legacy SysCasbinRule model is not the table
|
||||
// loaded by middleware.AuthCheckRole in this baseline.
|
||||
type runtimeCasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V0 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V1 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V2 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V3 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V4 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V5 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
}
|
||||
|
||||
func (runtimeCasbinRule) TableName() string { return "casbin_rule" }
|
||||
|
||||
func seedBellRuleAlertAccess(tx *gorm.DB) error {
|
||||
// db.sql contains explicit primary keys, so PostgreSQL sequences can lag
|
||||
// behind the imported baseline data. Align them before allocating any new
|
||||
// menu, API or role IDs.
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1));
|
||||
SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1));
|
||||
SELECT setval(pg_get_serial_sequence('sys_role','role_id'), GREATEST((SELECT max(role_id) FROM sys_role),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, err := insertMenu(tx, 0, "BellWarning", "预警中心", "warning", "/bell", "M", "", "", "Layout", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alerts, err := insertMenu(tx, root.ID, "BellAlerts", "预警管理", "bell", "alerts", "C", "bell:alert:list", "", "/bell/alerts/index", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events, err := insertMenu(tx, root.ID, "BellEvents", "事件查询", "list", "events", "C", "bell:event:list", "", "/bell/events/index", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := insertMenu(tx, root.ID, "BellRules", "规则配置", "guide", "rules", "C", "bell:rule:list", "", "/bell/rules/index", 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addRule, err := insertMenu(tx, rules.ID, "", "新增规则", "", "", "F", "bell:rule:add", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
editRule, err := insertMenu(tx, rules.ID, "", "修改规则", "", "", "F", "bell:rule:edit", "PUT", "", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiSpecs := []struct{ title, path, action string }{
|
||||
{"预警列表", "/api/v1/bell/alerts", "GET"}, {"预警详情", "/api/v1/bell/alerts/:id", "GET"},
|
||||
{"事件列表", "/api/v1/bell/events", "GET"}, {"事件详情", "/api/v1/bell/events/:id", "GET"},
|
||||
{"事件规则结果", "/api/v1/bell/events/:id/rule-results", "GET"},
|
||||
{"规则列表", "/api/v1/bell/rules", "GET"}, {"新增规则", "/api/v1/bell/rules", "POST"},
|
||||
{"修改规则", "/api/v1/bell/rules/:id", "PUT"}, {"启停规则", "/api/v1/bell/rules/:id/enabled", "PUT"},
|
||||
}
|
||||
apis := make([]apiSeed, 0, len(apiSpecs))
|
||||
for _, spec := range apiSpecs {
|
||||
seed, seedErr := insertAPI(tx, spec.title, spec.path, spec.action)
|
||||
if seedErr != nil {
|
||||
return seedErr
|
||||
}
|
||||
apis = append(apis, seed)
|
||||
}
|
||||
links := map[int][]apiSeed{
|
||||
alerts.ID: {apis[0], apis[1]}, events.ID: {apis[2], apis[3], apis[4]}, rules.ID: {apis[5]},
|
||||
addRule.ID: {apis[6]}, editRule.ID: {apis[7], apis[8]},
|
||||
}
|
||||
for menuID, menuAPIs := range links {
|
||||
for _, item := range menuAPIs {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id, sys_api_id) VALUES(?, ?) ON CONFLICT DO NOTHING", menuID, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var operatorRoleID int
|
||||
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key = 'operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operatorRoleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if operatorRoleID == 0 {
|
||||
if err := tx.Raw(`INSERT INTO sys_role(role_name,status,role_key,role_sort,flag,remark,admin,data_scope,create_by,update_by,created_at,updated_at)
|
||||
VALUES('处置员','2','operator',2,'','仅访问 Bell 预警处理入口',false,'',1,1,now(),now())
|
||||
RETURNING role_id`).Scan(&operatorRoleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, menu := range []menuSeed{root, alerts, events, rules} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id, menu_id) VALUES(?, ?) ON CONFLICT DO NOTHING", operatorRoleID, menu.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range apis {
|
||||
if item.Action != "GET" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertMenu(tx *gorm.DB, parentID int, name, title, icon, path, menuType, permission, action, component string, sort int) (menuSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`,
|
||||
name, title, icon, path, menuType, action, permission, parentID, component, sort).Scan(&id).Error
|
||||
if err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
paths := fmt.Sprintf("/0/%d", id)
|
||||
if parentID != 0 {
|
||||
var parentPaths string
|
||||
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id = ?", parentID).Scan(&parentPaths).Error; err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
paths = fmt.Sprintf("%s/%d", parentPaths, id)
|
||||
}
|
||||
if err = tx.Exec("UPDATE sys_menu SET paths = ? WHERE menu_id = ?", paths, id).Error; err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
return menuSeed{ID: id, Permission: permission}, nil
|
||||
}
|
||||
|
||||
func insertAPI(tx *gorm.DB, title, path, action string) (apiSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by)
|
||||
VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
|
||||
return apiSeed{ID: id, Path: path, Action: action}, err
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellAlertLifecycle)
|
||||
}
|
||||
|
||||
func migrateBellAlertLifecycle(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(alert_lifecycle.Fact), new(alert_lifecycle.RejectionAudit)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range alertLifecycleSQL {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedAlertLifecycleAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var alertLifecycleSQL = []string{
|
||||
`ALTER TABLE bell_alerts ADD COLUMN acknowledged_by bigint, ADD COLUMN acknowledged_by_name varchar(128), ADD COLUMN acknowledged_at timestamptz, ADD COLUMN closed_by bigint, ADD COLUMN closed_by_name varchar(128), ADD COLUMN closed_at timestamptz, ADD COLUMN close_outcome varchar(32), ADD COLUMN close_note varchar(500)`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_ack_user_fk FOREIGN KEY (acknowledged_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_user_fk FOREIGN KEY (closed_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_outcome_check CHECK (close_outcome IS NULL OR close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_actor_fk FOREIGN KEY (actor_id) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_transition_check CHECK (transition IN ('acknowledged','closed'))`,
|
||||
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_outcome_check CHECK (outcome IS NULL OR outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
|
||||
`CREATE TRIGGER bell_alert_lifecycle_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_alert_lifecycle_rejections_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_rejections FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
|
||||
func seedAlertLifecycleAccess(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1)); SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var alertMenuID int
|
||||
if err := tx.Table("sys_menu").Select("menu_id").Where("permission = ?", "bell:alert:list").Scan(&alertMenuID).Error; err != nil || alertMenuID == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
ackMenu, err := insertMenu(tx, alertMenuID, "", "开始处理", "", "", "F", "bell:alert:ack", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closeMenu, err := insertMenu(tx, alertMenuID, "", "记录结果", "", "", "F", "bell:alert:close", "POST", "", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := []struct{ title, path, action string }{{"预警处理时间线", "/api/v1/bell/alerts/:id/lifecycle", "GET"}, {"开始处理预警", "/api/v1/bell/alerts/:id/ack", "POST"}, {"记录结果并完成", "/api/v1/bell/alerts/:id/close", "POST"}}
|
||||
apis := make([]apiSeed, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
item, itemErr := insertAPI(tx, spec.title, spec.path, spec.action)
|
||||
if itemErr != nil {
|
||||
return itemErr
|
||||
}
|
||||
apis = append(apis, item)
|
||||
}
|
||||
for _, link := range []struct {
|
||||
menu int
|
||||
api apiSeed
|
||||
}{{alertMenuID, apis[0]}, {ackMenu.ID, apis[1]}, {closeMenu.ID, apis[2]}} {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", link.menu, link.api.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var operatorRoleID int
|
||||
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&operatorRoleID).Error; err != nil || operatorRoleID == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
for _, menuID := range []int{ackMenu.ID, closeMenu.ID} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operatorRoleID, menuID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range apis {
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellMinimalMenu)
|
||||
}
|
||||
|
||||
func migrateBellMinimalMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ApplyBellMinimalMenuVisibility(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyBellMinimalMenuVisibility keeps the imported GoAdmin menu records for
|
||||
// rollback and upgrades, but exposes only Bell product entries and the three
|
||||
// RBAC administration pages required to maintain local accounts.
|
||||
func ApplyBellMinimalMenuVisibility(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE sys_menu
|
||||
SET visible = '1', updated_at = now()
|
||||
WHERE menu_type IN ('M', 'C')
|
||||
AND deleted_at IS NULL
|
||||
AND visible IS DISTINCT FROM '1'
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
UPDATE sys_menu
|
||||
SET visible = '0', updated_at = now()
|
||||
WHERE menu_type IN ('M', 'C')
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
path IN ('/admin', '/admin/sys-user', '/admin/sys-menu', '/admin/sys-role', '/bell')
|
||||
OR permission IN ('admin:sysUser:list', 'admin:sysMenu:list', 'admin:sysRole:list',
|
||||
'bell:alert:list', 'bell:event:list', 'bell:rule:list')
|
||||
)
|
||||
AND visible IS DISTINCT FROM '0'
|
||||
`).Error
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventIngress)
|
||||
}
|
||||
|
||||
func migrateBellEventIngress(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(
|
||||
&event_ingress.ReplayToken{},
|
||||
&event_ingress.EvidenceStatus{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellEventIngressMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const version = "2026083112000"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err = migrateBellEventIngress(db, version); err != nil {
|
||||
t.Fatalf("migration attempt %d: %v", attempt+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, model := range map[string]any{
|
||||
"replay tokens": &event_ingress.ReplayToken{},
|
||||
"evidence statuses": &event_ingress.EvidenceStatus{},
|
||||
} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(model).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", name, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("migration inserted %d %s fixtures", count, name)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&event_ingress.ReplayToken{}, "ExpiresAt") {
|
||||
t.Fatal("replay expiry index missing")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := event_ingress.ReplayToken{Principal: "brain", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
|
||||
if err = db.Create(&token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&token).Error; err == nil {
|
||||
t.Fatal("duplicate replay token accepted")
|
||||
}
|
||||
|
||||
var applied int64
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("migration records=%d, want 1", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellContactSchedule)
|
||||
}
|
||||
func migrateBellContactSchedule(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&contact.Contact{}, &contact.Channel{}, &contact.ChannelValidation{}, &contact.AuditFact{}, &duty.Group{}, &duty.Member{}, &duty.ScheduleVersion{}, &duty.RotationSlot{}, &duty.Override{}, &duty.AuditFact{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
for _, sql := range contactScheduleSQL {
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedContactScheduleAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var contactScheduleSQL = []string{
|
||||
`ALTER TABLE bell_contacts ADD CONSTRAINT bell_contacts_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_kind_check CHECK (kind IN ('sms','voice'))`,
|
||||
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_contact_channel_identity_idx ON bell_contact_channels(contact_id,kind,address_fingerprint)`,
|
||||
`CREATE TRIGGER bell_contact_channels_immutable BEFORE UPDATE OR DELETE ON bell_contact_channels FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_status_check CHECK (status IN ('verified','failed'))`,
|
||||
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_channel_fk FOREIGN KEY (channel_id) REFERENCES bell_contact_channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_contact_audit_facts ADD CONSTRAINT bell_contact_audit_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_groups ADD CONSTRAINT bell_duty_groups_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_role_check CHECK (role IN ('primary','backup'))`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_duty_schedule_group_version_idx ON bell_duty_schedule_versions(group_id,version)`,
|
||||
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_status_check CHECK (status IN ('draft','published'))`,
|
||||
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE OR REPLACE FUNCTION bell_guard_schedule_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' OR OLD.status = 'published' THEN RAISE EXCEPTION 'Bell published schedule cannot be changed' USING ERRCODE = '55000'; END IF; IF NEW.status <> 'published' OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Bell schedule transition is invalid' USING ERRCODE = '55000'; END IF; RETURN NEW; END $$`,
|
||||
`CREATE TRIGGER bell_duty_schedule_version_guard BEFORE UPDATE OR DELETE ON bell_duty_schedule_versions FOR EACH ROW EXECUTE FUNCTION bell_guard_schedule_version()`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_range_check CHECK (weekday BETWEEN 0 AND 6 AND start_minute >= 0 AND end_minute <= 1440 AND start_minute < end_minute AND primary_contact_id <> backup_contact_id)`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_version_fk FOREIGN KEY (schedule_version_id) REFERENCES bell_duty_schedule_versions(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_primary_fk FOREIGN KEY (primary_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_backup_fk FOREIGN KEY (backup_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_range_check CHECK (starts_at < ends_at AND original_contact_id <> replacement_contact_id)`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_original_fk FOREIGN KEY (original_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_replacement_fk FOREIGN KEY (replacement_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_audit_facts ADD CONSTRAINT bell_duty_audit_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE TRIGGER bell_contact_validations_immutable BEFORE UPDATE OR DELETE ON bell_contact_channel_validations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_contact_audit_immutable BEFORE UPDATE OR DELETE ON bell_contact_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_slots_immutable BEFORE UPDATE OR DELETE ON bell_duty_rotation_slots FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_overrides_immutable BEFORE UPDATE OR DELETE ON bell_duty_overrides FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_audit_immutable BEFORE UPDATE OR DELETE ON bell_duty_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
|
||||
type contactScheduleSeed struct {
|
||||
ID int
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
func seedContactScheduleAccess(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'),GREATEST((SELECT max(menu_id) FROM sys_menu),1));SELECT setval(pg_get_serial_sequence('sys_api','id'),GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var rootID int
|
||||
if err := tx.Raw("SELECT menu_id FROM sys_menu WHERE path='/bell' AND parent_id=0 ORDER BY menu_id LIMIT 1").Scan(&rootID).Error; err != nil || rootID == 0 {
|
||||
return fmt.Errorf("Bell menu root missing")
|
||||
}
|
||||
contacts, err := insertContactScheduleMenu(tx, rootID, "BellContacts", "联系人与通道", "user", "contacts", "C", "bell:contact:list", "", "/bell/contacts/index", 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dutyMenu, err := insertContactScheduleMenu(tx, rootID, "BellDutySchedules", "值班排班", "time", "duty-schedules", "C", "bell:duty:list", "", "/bell/duty-schedules/index", 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contactWrite, err := insertContactScheduleMenu(tx, contacts.ID, "", "维护联系人", "", "", "F", "bell:contact:write", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dutyWrite, err := insertContactScheduleMenu(tx, dutyMenu.ID, "", "维护排班", "", "", "F", "bell:duty:write", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := []struct{ title, path, action string }{
|
||||
{"联系人列表", "/api/v1/bell/contacts", "GET"}, {"新增联系人", "/api/v1/bell/contacts", "POST"}, {"修改联系人", "/api/v1/bell/contacts/:id", "PUT"}, {"启停联系人", "/api/v1/bell/contacts/:id/enabled", "PUT"}, {"新增联系通道", "/api/v1/bell/contacts/:id/channels", "POST"}, {"记录通道验证", "/api/v1/bell/contact-channels/:id/validations", "POST"},
|
||||
{"值班组列表", "/api/v1/bell/duty-groups", "GET"}, {"新增值班组", "/api/v1/bell/duty-groups", "POST"}, {"修改值班组", "/api/v1/bell/duty-groups/:id", "PUT"}, {"保存值班成员", "/api/v1/bell/duty-groups/:id/members", "POST"}, {"新增排班版本", "/api/v1/bell/duty-groups/:id/schedules", "POST"}, {"发布排班版本", "/api/v1/bell/duty-schedules/:id/publish", "POST"}, {"新增临时替班", "/api/v1/bell/duty-groups/:id/overrides", "POST"},
|
||||
}
|
||||
apis := make([]contactScheduleSeed, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
v, e := insertContactScheduleAPI(tx, s.title, s.path, s.action)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
apis = append(apis, v)
|
||||
}
|
||||
links := map[int][]contactScheduleSeed{contacts.ID: {apis[0]}, contactWrite.ID: apis[1:6], dutyMenu.ID: {apis[6]}, dutyWrite.ID: apis[7:]}
|
||||
for menu, items := range links {
|
||||
for _, item := range items {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", menu, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var operator int
|
||||
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key='operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operator).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if operator != 0 {
|
||||
for _, menu := range []contactScheduleSeed{contacts, dutyMenu} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operator, menu.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range []contactScheduleSeed{apis[0], apis[6]} {
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func insertContactScheduleMenu(tx *gorm.DB, parent int, name, title, icon, path, menuType, permission, action, component string, sort int) (contactScheduleSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at) VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`, name, title, icon, path, menuType, action, permission, parent, component, sort).Scan(&id).Error
|
||||
if err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
var parentPaths string
|
||||
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id=?", parent).Scan(&parentPaths).Error; err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
if err = tx.Exec("UPDATE sys_menu SET paths=? WHERE menu_id=?", fmt.Sprintf("%s/%d", parentPaths, id), id).Error; err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
return contactScheduleSeed{ID: id}, nil
|
||||
}
|
||||
func insertContactScheduleAPI(tx *gorm.DB, title, path, action string) (contactScheduleSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by) VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
|
||||
return contactScheduleSeed{ID: id, Path: path, Action: action}, err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellContactScheduleMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = migrateBellContactSchedule(db, "2026090110000"); err != nil {
|
||||
t.Fatalf("attempt %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
for name, model := range map[string]any{"contacts": &contact.Contact{}, "channels": &contact.Channel{}, "validations": &contact.ChannelValidation{}, "groups": &duty.Group{}, "members": &duty.Member{}, "versions": &duty.ScheduleVersion{}, "slots": &duty.RotationSlot{}, "overrides": &duty.Override{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&common.Migration{}).Where("version=?", "2026090110000").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("migration records=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package bell_alert_lifecycle_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/rule"
|
||||
)
|
||||
|
||||
func TestConcurrentLifecycleAndPersistence(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("integration database not configured")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
actors := createActors(t, db)
|
||||
service := alert_lifecycle.NewService(db)
|
||||
alertID := createAlert(t, db, "main")
|
||||
const attempts = 20
|
||||
var won atomic.Int32
|
||||
results := make(chan alert_lifecycle.Result, attempts)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < attempts; i++ {
|
||||
wg.Add(1)
|
||||
actor := actors[i%2]
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, _ := service.Ack(ctx, alertID, actor)
|
||||
if result.Won {
|
||||
won.Add(1)
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
if won.Load() != 1 {
|
||||
t.Fatalf("ack winners=%d", won.Load())
|
||||
}
|
||||
detail, err := service.Get(ctx, alertID)
|
||||
if err != nil || detail.Projection.Status != alert_lifecycle.StatusAcknowledged || len(detail.Timeline) != 1 {
|
||||
t.Fatalf("ack projection=%#v err=%v", detail, err)
|
||||
}
|
||||
winner := actors[0]
|
||||
loser := actors[1]
|
||||
if detail.Projection.AcknowledgedBy == nil || *detail.Projection.AcknowledgedBy != winner.ID {
|
||||
winner, loser = loser, winner
|
||||
}
|
||||
for result := range results {
|
||||
if result.Detail.Projection.AcknowledgedBy != nil && *result.Detail.Projection.AcknowledgedBy != winner.ID {
|
||||
t.Fatal("later ack did not report the true winner")
|
||||
}
|
||||
}
|
||||
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal"}, loser); err == nil {
|
||||
t.Fatal("non-owner close succeeded")
|
||||
}
|
||||
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{}, winner); err == nil {
|
||||
t.Fatal("missing outcome succeeded")
|
||||
}
|
||||
closed, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
|
||||
if err != nil || !closed.Won {
|
||||
t.Fatalf("close failed: %#v %v", closed, err)
|
||||
}
|
||||
replay, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
|
||||
if err != nil || !replay.Idempotent {
|
||||
t.Fatalf("close replay=%#v %v", replay, err)
|
||||
}
|
||||
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "false_positive"}, winner); err == nil {
|
||||
t.Fatal("conflicting close replay succeeded")
|
||||
}
|
||||
if err = db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Update("actor_name", "tampered").Error; err == nil {
|
||||
t.Fatal("lifecycle fact update succeeded")
|
||||
}
|
||||
var facts, rejects int64
|
||||
db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Count(&facts)
|
||||
db.Model(&alert_lifecycle.RejectionAudit{}).Where("alert_id = ?", alertID).Count(&rejects)
|
||||
if facts != 2 || rejects < 20 {
|
||||
t.Fatalf("facts=%d rejects=%d", facts, rejects)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
_ = sqlDB.Close()
|
||||
reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := alert_lifecycle.NewService(reopened).Get(ctx, alertID)
|
||||
if err != nil || after.Projection.Status != alert_lifecycle.StatusClosed || len(after.Timeline) != 2 {
|
||||
t.Fatalf("restart detail=%#v err=%v", after, err)
|
||||
}
|
||||
|
||||
rollbackID := createAlert(t, reopened, "rollback")
|
||||
if err = reopened.Exec(`CREATE FUNCTION bell_test_reject_lifecycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced lifecycle failure'; END $$`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = reopened.Exec(`CREATE TRIGGER bell_test_reject_lifecycle BEFORE INSERT ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, rollbackID, winner); err == nil {
|
||||
t.Fatal("forced lifecycle failure succeeded")
|
||||
}
|
||||
rollback, _ := alert_lifecycle.NewService(reopened).Get(ctx, rollbackID)
|
||||
if rollback.Projection.Status != alert_lifecycle.StatusOpen || len(rollback.Timeline) != 0 {
|
||||
t.Fatal("failed ack left partial projection")
|
||||
}
|
||||
if err = reopened.Exec(`DROP TRIGGER bell_test_reject_lifecycle ON bell_alert_lifecycle_facts; DROP FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminCloseID := createAlert(t, reopened, "admin-close")
|
||||
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, adminCloseID, winner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
administrator := loser
|
||||
administrator.Role = "admin"
|
||||
if result, closeErr := alert_lifecycle.NewService(reopened).Close(ctx, adminCloseID, alert_lifecycle.CloseInput{Outcome: "danger_confirmed"}, administrator); closeErr != nil || !result.Won {
|
||||
t.Fatalf("administrator close failed: %#v %v", result, closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func createActors(t *testing.T, db *gorm.DB) []alert_lifecycle.Actor {
|
||||
t.Helper()
|
||||
var roleID int
|
||||
db.Table("sys_role").Select("role_id").Where("role_key='operator'").Scan(&roleID)
|
||||
result := make([]alert_lifecycle.Actor, 2)
|
||||
for i := range result {
|
||||
user := adminmodels.SysUser{Username: "bell_133_operator_" + string(rune('a'+i)), Password: "test-password-133", NickName: "处置员" + string(rune('A'+i)), RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result[i] = alert_lifecycle.Actor{ID: user.UserId, Name: user.NickName, Role: "operator"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func createAlert(t *testing.T, db *gorm.DB, suffix string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
eventType := "lifecycle_" + suffix
|
||||
createdRule, err := rule.NewService(db).Create(ctx, rule.WriteInput{Code: "lifecycle-" + suffix, Name: "生命周期规则", EventType: &eventType, MinimumSeverity: "low"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := event.NewService(db).Ingest(ctx, event.Command{ProducerID: "bell.lifecycle-test", SourceEventID: suffix, EventType: eventType, OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: "测试地点" + suffix, Severity: "high", Attributes: map[string]any{}}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, _, err := alert.NewService(db).List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.PrimaryRuleID == createdRule.ID {
|
||||
return item.ID
|
||||
}
|
||||
}
|
||||
t.Fatal("alert not created")
|
||||
return created.Event.ID
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[CmdletBinding()] param([string]$PostgresBin='D:\pgsql17\bin')
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference='Stop'; $started=$false; $server=$null
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-133-'+[guid]::NewGuid().ToString('N'))
|
||||
$data=Join-Path $root 'postgres'; $log=Join-Path $root 'postgres.log'; $serverRoot=(Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path; $serverExe=Join-Path $root 'bell.exe'
|
||||
function FreePort { $l=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0); try{$l.Start();return ([Net.IPEndPoint]$l.LocalEndpoint).Port}finally{$l.Stop()} }
|
||||
function WaitPort([int]$port){for($i=0;$i -lt 120;$i++){try{$c=[Net.Sockets.TcpClient]::new();$ok=$c.ConnectAsync('127.0.0.1',$port).Wait(250)-and$c.Connected;$c.Dispose();if($ok){return}}catch{};Start-Sleep -Milliseconds 250};throw 'PostgreSQL did not start'}
|
||||
function Login([string]$base,[string]$username,[string]$password){$body=@{username=$username;password=$password;code='0';uuid='0'}|ConvertTo-Json -Compress; $result=Invoke-RestMethod -Method Post -Uri "$base/api/v1/login" -ContentType 'application/json' -Body $body -NoProxy; if([int]$result.code-ne 200){throw "login failed: $username"}; return @{Authorization="Bearer $($result.token)"}}
|
||||
New-Item -ItemType Directory -Path $root|Out-Null; $port=FreePort
|
||||
try {
|
||||
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){if(-not(Test-Path (Join-Path $PostgresBin $name))){throw "Missing $name"}}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale|Out-Null; if($LASTEXITCODE-ne 0){throw 'initdb failed'}
|
||||
$args="-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"; Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $args -WindowStyle Hidden|Out-Null; WaitPort $port; $started=$true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_133; if($LASTEXITCODE-ne 0){throw 'createdb failed'}
|
||||
$bellPort=FreePort; $base="http://127.0.0.1:$bellPort"; $env:GOTOOLCHAIN='go1.26.5'; $env:BELL_DATABASE_URL="host=127.0.0.1 port=$port user=postgres dbname=bell_133 sslmode=disable"; $env:BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL=$env:BELL_DATABASE_URL; $env:BELL_JWT_SECRET=[guid]::NewGuid().ToString('N')+[guid]::NewGuid().ToString('N'); $env:BELL_BOOTSTRAP_USERNAME='bell_133_admin'; $env:BELL_BOOTSTRAP_PASSWORD=[guid]::NewGuid().ToString('N'); $env:BELL_HOST='127.0.0.1'; $env:BELL_PORT=$bellPort.ToString()
|
||||
Push-Location $serverRoot; try { go run . migrate -c config/settings.demo.yml *> (Join-Path $root 'migrate.log'); if($LASTEXITCODE-ne 0){throw "migration failed: $root"}; go test ./tests/bell_alert_lifecycle -count=1 -v; if($LASTEXITCODE-ne 0){throw 'lifecycle test failed'}; go build -o $serverExe . } finally { Pop-Location }
|
||||
$server=Start-Process $serverExe -ArgumentList @('server','-c','config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput (Join-Path $root 'server.out') -RedirectStandardError (Join-Path $root 'server.err') -WindowStyle Hidden -PassThru; WaitPort $bellPort
|
||||
$a=Login $base 'bell_133_operator_a' 'test-password-133'; $b=Login $base 'bell_133_operator_b' 'test-password-133'; $list=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts?status=open" -Headers $a -NoProxy; $id=[string]$list.data.list[0].id; if([string]::IsNullOrWhiteSpace($id)){throw 'open alert missing'}
|
||||
$ack=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; $late=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $b -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$ack.code-ne 200-or[int]$late.code-ne 409){throw 'ack API semantics failed'}
|
||||
$forbidden=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $b -ContentType 'application/json' -Body '{"outcome":"site_normal"}' -NoProxy; $missing=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$forbidden.code-ne 403-or[int]$missing.code-ne 400){throw 'close rejection semantics failed'}
|
||||
$body='{"outcome":"site_normal","note":"现场正常"}'; $closed=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $replay=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $timeline=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts/$id/lifecycle" -Headers $a -NoProxy; if([int]$closed.code-ne 200-or-not$replay.data.idempotent-or$timeline.data.detail.timeline.Count-ne 2){throw 'close/timeline API semantics failed'}
|
||||
$leaks=& (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $port -U postgres -d bell_133 -Atc "select count(*) from sys_opera_log where oper_url like '%/bell/alerts/%/close' and ((oper_param <> '' and oper_param not like '%redacted%') or json_result not like '%redacted%');"; if($LASTEXITCODE-ne 0-or[int]$leaks-ne 0){throw 'lifecycle note leaked into GoAdmin operation log'}
|
||||
Write-Output 'BELL_133_HTTP ack=200 late_ack=409 forbidden_close=403 missing_outcome=400 close=200 replay=true timeline=2'
|
||||
} finally {
|
||||
if($null-ne$server-and-not$server.HasExited){Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000)|Out-Null}
|
||||
if($started){& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')}
|
||||
foreach($name in @('BELL_DATABASE_URL','BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')){Remove-Item "Env:$name" -ErrorAction SilentlyContinue}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package bell_alert_lifecycle_test
|
||||
|
||||
import "testing"
|
||||
|
||||
// Input and state validation are exercised through the PostgreSQL service test;
|
||||
// this sentinel keeps the package runnable without an integration database.
|
||||
func TestLifecyclePackageLoadsWithoutDatabase(t *testing.T) {}
|
||||
@@ -0,0 +1,169 @@
|
||||
package bell_contact_schedule_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestContactSchedulePostgres(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL to run PostgreSQL verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
contacts := contact.NewService(db, key)
|
||||
|
||||
primary, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人甲", Role: "主值班"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人乙", Role: "备值班"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primaryChannel, err := contacts.AddChannel(ctx, primary.ID, contact.ChannelInput{Kind: "sms", Address: "+8613800000001"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backupChannel, err := contacts.AddChannel(ctx, backup.ID, contact.ChannelInput{Kind: "voice", Address: "+8613800000002"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(primaryChannel.AddressMasked, "13800000001") {
|
||||
t.Fatal("channel response leaked address")
|
||||
}
|
||||
plain, err := contacts.DecryptChannelAddress(ctx, primaryChannel.ID)
|
||||
if err != nil || plain != "+8613800000001" {
|
||||
t.Fatalf("server-only decrypt failed: %q %v", plain, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(primaryChannel)
|
||||
if strings.Contains(string(encoded), plain) {
|
||||
t.Fatal("serialized channel leaked plaintext")
|
||||
}
|
||||
if _, err = contacts.RecordValidation(ctx, primaryChannel.ID, "verified", "合成验证", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.RecordValidation(ctx, backupChannel.ID, "verified", "合成验证", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.Update(ctx, primary.ID, contact.WriteInput{Name: "联系人甲", Role: "主值班", ExpectedVersion: 99}, 1); !errors.Is(err, contact.ErrConflict) {
|
||||
t.Fatalf("stale contact update err=%v", err)
|
||||
}
|
||||
if _, err = contacts.SetEnabled(ctx, primary.ID, false, primary.Version, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, _, err := contacts.List(ctx, contact.PageQuery{PageIndex: 1, PageSize: 20})
|
||||
if err != nil || len(listed) != 2 {
|
||||
t.Fatalf("contact list len=%d err=%v", len(listed), err)
|
||||
}
|
||||
var primaryView *contact.ContactView
|
||||
for index := range listed {
|
||||
if listed[index].ID == primary.ID {
|
||||
primaryView = &listed[index]
|
||||
}
|
||||
}
|
||||
if primaryView == nil || primaryView.Enabled || len(primaryView.Channels) != 1 || primaryView.Channels[0].Status != "verified" {
|
||||
t.Fatalf("contact enabled state was coupled to validation: %#v", primaryView)
|
||||
}
|
||||
// Re-enable with the new version before assigning duty.
|
||||
var disabled contact.Contact
|
||||
if err = db.First(&disabled, "id=?", primary.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.SetEnabled(ctx, primary.ID, true, disabled.Version, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duties := duty.NewService(db)
|
||||
group, err := duties.CreateGroup(ctx, duty.GroupInput{Name: "夜间值班组", Timezone: "Asia/Shanghai"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: primary.ID, Role: "primary"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: backup.ID, Role: "backup"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: []duty.SlotInput{{Weekday: 0, StartMinute: 0, EndMinute: 720, PrimaryContactID: primary.ID, BackupContactID: backup.ID}}}, 1); !errors.Is(err, duty.ErrCoverage) {
|
||||
t.Fatalf("schedule gap was accepted: %v", err)
|
||||
}
|
||||
slots := make([]duty.SlotInput, 0, 7)
|
||||
for day := 0; day < 7; day++ {
|
||||
slots = append(slots, duty.SlotInput{Weekday: day, StartMinute: 0, EndMinute: 1440, PrimaryContactID: primary.ID, BackupContactID: backup.ID})
|
||||
}
|
||||
v1, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: slots}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published, err := duties.Publish(ctx, v1.ID, 1)
|
||||
if err != nil || published.Status != "published" {
|
||||
t.Fatalf("publish status=%s err=%v", published.Status, err)
|
||||
}
|
||||
v2, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(24 * time.Hour), Slots: slots}, 1)
|
||||
if err != nil || v2.Version != 2 {
|
||||
t.Fatalf("second version=%d err=%v", v2.Version, err)
|
||||
}
|
||||
var persisted duty.ScheduleVersion
|
||||
if err = db.First(&persisted, "id=?", v1.ID).Error; err != nil || persisted.Version != 1 || persisted.Status != "published" {
|
||||
t.Fatalf("historical version changed: %#v err=%v", persisted, err)
|
||||
}
|
||||
now := time.Now().UTC().Add(2 * time.Hour)
|
||||
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now, EndsAt: now.Add(time.Hour), Reason: "合成替班"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now.Add(30 * time.Minute), EndsAt: now.Add(90 * time.Minute), Reason: "重叠替班"}, 1); !errors.Is(err, duty.ErrConflict) {
|
||||
t.Fatalf("overlap err=%v", err)
|
||||
}
|
||||
|
||||
if err = db.Model(&contact.ChannelValidation{}).Where("channel_id=?", primaryChannel.ID).Update("detail", "tampered").Error; err == nil {
|
||||
t.Fatal("validation fact was mutable")
|
||||
}
|
||||
if err = db.Model(&duty.RotationSlot{}).Where("schedule_version_id=?", v1.ID).Update("start_minute", 1).Error; err == nil {
|
||||
t.Fatal("published rotation slot was mutable")
|
||||
}
|
||||
if err = db.Model(&duty.ScheduleVersion{}).Where("id=?", v1.ID).Update("effective_from", time.Now().UTC()).Error; err == nil {
|
||||
t.Fatal("published schedule version was mutable")
|
||||
}
|
||||
var menus, reads, writes int64
|
||||
if err = db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id=rm.role_id").Joins("JOIN sys_menu m ON m.menu_id=rm.menu_id").Where("r.role_key=? AND m.path IN ?", "operator", []string{"contacts", "duty-schedules"}).Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("v0=? AND v2=? AND v1 IN ?", "operator", "GET", []string{"/api/v1/bell/contacts", "/api/v1/bell/duty-groups"}).Count(&reads).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("v0=? AND v2<>? AND (v1 LIKE ? OR v1 LIKE ?)", "operator", "GET", "/api/v1/bell/contacts%", "/api/v1/bell/duty-%").Count(&writes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if menus != 2 || reads != 2 || writes != 0 {
|
||||
t.Fatalf("operator access escaped scope: menus=%d reads=%d writes=%d", menus, reads, writes)
|
||||
}
|
||||
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
|
||||
if password != "" {
|
||||
var roleID int
|
||||
if err = db.Table("sys_role").Select("role_id").Where("role_key=?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("operator role id=%d err=%v", roleID, err)
|
||||
}
|
||||
user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pgStarted = $false
|
||||
$server = $null
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-183-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $testRoot 'postgres'
|
||||
$log = Join-Path $testRoot 'postgres.log'
|
||||
$pgOut = Join-Path $testRoot 'pg.out'
|
||||
$pgErr = Join-Path $testRoot 'pg.err'
|
||||
$serverOut = Join-Path $testRoot 'bell.out.log'
|
||||
$serverErr = Join-Path $testRoot 'bell.err.log'
|
||||
$serverExe = Join-Path $testRoot 'bell-server.exe'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
function Wait-Port([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$open = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($open) { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "PostgreSQL port $Port did not open"
|
||||
}
|
||||
function Wait-Health([string]$BaseUrl) {
|
||||
for ($attempt = 0; $attempt -lt 100; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
|
||||
if ($health.status -eq 'ok') { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw 'Bell health endpoint did not become ready'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreePort
|
||||
$bellPort = Get-FreePort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name) -PathType Leaf)) { throw "Missing PostgreSQL tool: $name" }
|
||||
}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
|
||||
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -RedirectStandardOutput $pgOut -RedirectStandardError $pgErr -WindowStyle Hidden | Out-Null
|
||||
Wait-Port $pgPort
|
||||
$pgStarted = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres bell_183
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=bell_183 sslmode=disable"
|
||||
$env:BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_183_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_CONTACT_CHANNEL_KEY = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef0123456789abcdef'))
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = $bellPort.ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed: $(Join-Path $testRoot 'migrate.log')" }
|
||||
go test ./tests/bell_contact_schedule -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'contact schedule tests failed' }
|
||||
go build -o $serverExe .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
|
||||
} finally { Pop-Location }
|
||||
|
||||
$server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $baseUrl
|
||||
$adminBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$admin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminBody -NoProxy
|
||||
$adminHeaders = @{ Authorization = "Bearer $($admin.token)" }
|
||||
$contactBody = @{ name = 'HTTP联系人'; role = '测试值班' } | ConvertTo-Json -Compress
|
||||
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $contactBody -NoProxy
|
||||
if ([int]$created.code -ne 200) { throw 'administrator contact create failed' }
|
||||
$address = '+8613900000003'
|
||||
$channelBody = @{ kind = 'sms'; address = $address } | ConvertTo-Json -Compress
|
||||
$channel = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts/$($created.data.id)/channels" -Headers $adminHeaders -ContentType 'application/json' -Body $channelBody -NoProxy
|
||||
if ([int]$channel.code -ne 200 -or ($channel | ConvertTo-Json -Depth 10 -Compress).Contains($address)) { throw 'write-only channel HTTP boundary failed' }
|
||||
$logRow = ''
|
||||
for ($attempt = 0; $attempt -lt 40; $attempt++) {
|
||||
$logRow = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $pgPort -U postgres -d bell_183 -Atc "SELECT id::text || '|' || coalesce(oper_param,'') FROM sys_opera_log WHERE oper_url LIKE '/api/v1/bell/contacts/%/channels' ORDER BY id DESC LIMIT 1"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'operation log query failed' }
|
||||
if ($logRow) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if (-not $logRow -or $logRow.Contains($address)) { throw "operation log redaction failed: $logRow" }
|
||||
$loggedBody = ($logRow -split '\|', 2)[1]
|
||||
if ($loggedBody -and -not $loggedBody.Contains('"redacted":true')) { throw "unexpected operation log marker: $loggedBody" }
|
||||
|
||||
$operatorBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$operator = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorBody -NoProxy
|
||||
$operatorHeaders = @{ Authorization = "Bearer $($operator.token)" }
|
||||
foreach ($path in @('/api/v1/bell/contacts', '/api/v1/bell/duty-groups')) {
|
||||
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -NoProxy
|
||||
if ([int]$read.code -ne 200) { throw "operator read failed: $path" }
|
||||
}
|
||||
$denied = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $operatorHeaders -ContentType 'application/json' -Body $contactBody -NoProxy
|
||||
if ([int]$denied.code -ne 403) { throw "operator write returned $($denied.code)" }
|
||||
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -NoProxy
|
||||
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
|
||||
foreach ($title in @('联系人与通道', '值班排班')) { if (-not $menuJson.Contains($title)) { throw "operator menu missing $title" } }
|
||||
Write-Output 'BELL_183_HTTP admin_contact=200 channel_write_only=true operator_reads=200 operator_write=403 menus=true'
|
||||
} finally {
|
||||
if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000) | Out-Null }
|
||||
if ($pgStarted) { & (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $testRoot 'stop.log') }
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_RULE_ALERT_OPERATOR_PASSWORD', 'BELL_CONTACT_CHANNEL_KEY', 'BELL_HOST', 'BELL_PORT')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue }
|
||||
Write-Verbose "Bell #183 artifacts: $testRoot"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package bell_contact_schedule_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
)
|
||||
|
||||
func TestChannelKeyAndWriteOnlyRoundTrip(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
key, err := contact.ParseChannelKey(encoded)
|
||||
if err != nil || len(key) != 32 {
|
||||
t.Fatalf("key parse failed: len=%d err=%v", len(key), err)
|
||||
}
|
||||
for _, value := range []string{"", "short", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))} {
|
||||
if _, err = contact.ParseChannelKey(value); !errors.Is(err, contact.ErrChannelKeyUnavailable) {
|
||||
t.Fatalf("invalid key accepted: %q err=%v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package bell_event_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
)
|
||||
|
||||
func validCommand() event.Command {
|
||||
return event.Command{
|
||||
ProducerID: " bell.synthetic ", SourceEventID: " test-001 ", EventType: " danger_area_entered ",
|
||||
OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)),
|
||||
Location: " 东门 ", Severity: " HIGH ", Attributes: map[string]any{"z": 2, "a": "first"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIsDeterministicAndTrimsBusinessFields(t *testing.T) {
|
||||
first, err := event.Normalize(validCommand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondCommand := validCommand()
|
||||
secondCommand.Attributes = map[string]any{"a": "first", "z": 2}
|
||||
second, err := event.Normalize(secondCommand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Digest != second.Digest || string(first.Payload) != string(second.Payload) {
|
||||
t.Fatalf("normalized payload is not deterministic: %s != %s", first.Payload, second.Payload)
|
||||
}
|
||||
if first.Command.ProducerID != "bell.synthetic" || first.Command.Severity != "high" {
|
||||
t.Fatalf("fields were not normalized: %#v", first.Command)
|
||||
}
|
||||
if first.Command.OccurredAt.Location() != time.UTC {
|
||||
t.Fatalf("occurredAt was not converted to UTC: %v", first.Command.OccurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsUnsafeOrInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*event.Command)
|
||||
}{
|
||||
{name: "missing source id", mutate: func(c *event.Command) { c.SourceEventID = " " }},
|
||||
{name: "unknown severity", mutate: func(c *event.Command) { c.Severity = "urgent" }},
|
||||
{name: "newline in location", mutate: func(c *event.Command) { c.Location = "东门\nsecret" }},
|
||||
{name: "unsafe evidence path", mutate: func(c *event.Command) { value := `C:\\secret.jpg`; c.EvidenceRef = &value }},
|
||||
{name: "oversized attributes", mutate: func(c *event.Command) { c.Attributes = map[string]any{"blob": string(make([]byte, 49*1024))} }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
command := validCommand()
|
||||
test.mutate(&command)
|
||||
if _, err := event.Normalize(command); !errors.Is(err, event.ErrInvalid) {
|
||||
t.Fatalf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package bell_event_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/synthetic"
|
||||
)
|
||||
|
||||
func TestSyntheticEnabledRequiresExplicitNonProductionOptIn(t *testing.T) {
|
||||
getenv := func(name string) string {
|
||||
if name == synthetic.EnabledEnv {
|
||||
return "true"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if synthetic.Enabled("prod", getenv) || synthetic.Enabled("production", getenv) {
|
||||
t.Fatal("synthetic route must stay disabled in production even when the flag is set")
|
||||
}
|
||||
if !synthetic.Enabled("dev", getenv) {
|
||||
t.Fatal("explicit development opt-in should enable the synthetic route")
|
||||
}
|
||||
if synthetic.Enabled("dev", func(string) string { return "" }) {
|
||||
t.Fatal("synthetic route must default to disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyntheticAdministratorCheckUsesJWTClaims(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
admin, _ := gin.CreateTestContext(nil)
|
||||
admin.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "admin"})
|
||||
if !synthetic.IsAdministrator(admin) {
|
||||
t.Fatal("admin role should be authorized")
|
||||
}
|
||||
operator, _ := gin.CreateTestContext(nil)
|
||||
operator.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "operator"})
|
||||
if synthetic.IsAdministrator(operator) {
|
||||
t.Fatal("non-admin role must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package bell_minimal_menu_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
versionlocal "go-admin/cmd/migrate/migration/version-local"
|
||||
)
|
||||
|
||||
var expectedVisibleMenus = []string{
|
||||
"事件查询",
|
||||
"用户管理",
|
||||
"系统管理",
|
||||
"菜单管理",
|
||||
"角色管理",
|
||||
"规则配置",
|
||||
"预警中心",
|
||||
"预警管理",
|
||||
}
|
||||
|
||||
func TestBellMinimalMenuMigration(t *testing.T) {
|
||||
databaseURL := os.Getenv("BELL_MINIMAL_MENU_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("minimal menu database is not configured")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertVisibleMenus(t, db)
|
||||
assertUnusedMenusRetainedAndHidden(t, db)
|
||||
assertOperatorHasNoDefaultMenus(t, db)
|
||||
|
||||
var rowCountBefore int64
|
||||
if err = db.Table("sys_menu").Count(&rowCountBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`UPDATE sys_menu SET visible = '0' WHERE title IN ('开发工具','定时任务','系统工具')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`UPDATE sys_menu SET visible = '1' WHERE title IN ('系统管理','预警中心')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
|
||||
t.Fatalf("reapplying minimal menu policy failed: %v", err)
|
||||
}
|
||||
var rowCountAfter int64
|
||||
if err = db.Table("sys_menu").Count(&rowCountAfter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rowCountAfter != rowCountBefore {
|
||||
t.Fatalf("menu records changed during visibility migration: before=%d after=%d", rowCountBefore, rowCountAfter)
|
||||
}
|
||||
assertVisibleMenus(t, db)
|
||||
assertUnusedMenusRetainedAndHidden(t, db)
|
||||
}
|
||||
|
||||
func assertVisibleMenus(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var titles []string
|
||||
if err := db.Table("sys_menu").
|
||||
Where("menu_type IN ? AND deleted_at IS NULL AND visible = ?", []string{"M", "C"}, "0").
|
||||
Order("title").Pluck("title", &titles).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(titles)
|
||||
expected := append([]string(nil), expectedVisibleMenus...)
|
||||
sort.Strings(expected)
|
||||
if !reflect.DeepEqual(titles, expected) {
|
||||
t.Fatalf("visible menu mismatch\nwant: %v\n got: %v", expected, titles)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUnusedMenusRetainedAndHidden(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, title := range []string{"开发工具", "定时任务", "系统工具"} {
|
||||
var values []string
|
||||
if err := db.Table("sys_menu").Where("title = ? AND deleted_at IS NULL", title).Pluck("visible", &values).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
t.Fatalf("unused upstream menu %q was deleted", title)
|
||||
}
|
||||
for _, visible := range values {
|
||||
if visible != "1" {
|
||||
t.Fatalf("unused upstream menu %q remains visible=%q", title, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertOperatorHasNoDefaultMenus(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var titles []string
|
||||
err := db.Raw(`
|
||||
SELECT DISTINCT m.title
|
||||
FROM sys_role r
|
||||
JOIN sys_role_menu rm ON rm.role_id = r.role_id
|
||||
JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE r.role_key = 'operator'
|
||||
AND m.menu_type IN ('M', 'C')
|
||||
AND m.deleted_at IS NULL
|
||||
AND (m.path LIKE '/admin%' OR m.permission LIKE 'admin:%')
|
||||
`).Scan(&titles).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(titles) != 0 {
|
||||
t.Fatalf("operator retains default administration menus: %v", titles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$started = $false
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-140-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $root 'postgres'
|
||||
$log = Join-Path $root 'postgres.log'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForPort([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($connected) { return }
|
||||
} catch {
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw 'PostgreSQL did not start'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
$port = Get-FreePort
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { throw "Missing $name" }
|
||||
}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
|
||||
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
|
||||
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
|
||||
Wait-ForPort $port
|
||||
$started = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_140
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_140 sslmode=disable"
|
||||
$env:BELL_MINIMAL_MENU_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_140_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = (Get-FreePort).ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
|
||||
go test ./tests/bell_minimal_menu -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'minimal menu test failed' }
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate-repeat.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "repeat migration failed; evidence: $root" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Output 'BELL_140_MINIMAL_MENU fresh=true upgrade=true repeat=true admin_whitelist=true operator_default_menu=false'
|
||||
} finally {
|
||||
if ($started) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_MINIMAL_MENU_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package bell_production_login_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
sdkapi "github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
adminrouter "go-admin/app/admin/router"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/common/bellconfig"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/middleware"
|
||||
"go-admin/common/storage"
|
||||
ext "go-admin/config"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ID string `json:"id"`
|
||||
Msg string `json:"msg"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func TestProductionCaptchaLoginAndRouteBoundary(t *testing.T) {
|
||||
if os.Getenv("BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("production login database is not configured")
|
||||
}
|
||||
if err := os.MkdirAll("temp/logs", 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gin.SetMode(gin.TestMode)
|
||||
config.ExtendConfig = &ext.ExtConfig
|
||||
config.Setup(file.NewSource(file.WithPath("../../config/settings.yml")))
|
||||
if err := bellconfig.ApplyRequiredEnvironment(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.ApplicationConfig.Mode != "prod" {
|
||||
t.Fatalf("expected production mode, got %q", config.ApplicationConfig.Mode)
|
||||
}
|
||||
database.Setup()
|
||||
storage.Setup()
|
||||
|
||||
engine := gin.New()
|
||||
sdk.Runtime.SetEngine(engine)
|
||||
engine.Use(sdkapi.SetRequestLogger)
|
||||
engine.Use(middleware.WithContextDb)
|
||||
authMiddleware, err := middleware.AuthInit()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminrouter.InitSysRouter(engine, authMiddleware)
|
||||
adminrouter.InitExamplesRouter(engine, authMiddleware)
|
||||
bellrouter.InitRouter()
|
||||
|
||||
captchaResponse := requestJSON(t, engine, http.MethodGet, "/api/v1/captcha", nil, "")
|
||||
if captchaResponse.Code != 200 || captchaResponse.ID == "" || !bytes.Contains(captchaResponse.Data, []byte("data:image/")) {
|
||||
t.Fatalf("unexpected captcha response: code=%d id=%q data=%s", captchaResponse.Code, captchaResponse.ID, captchaResponse.Data)
|
||||
}
|
||||
|
||||
username := os.Getenv("BELL_BOOTSTRAP_USERNAME")
|
||||
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
|
||||
answer := "813907"
|
||||
validID := "bell-138-valid"
|
||||
if err := base64Captcha.DefaultMemStore.Set(validID, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !captcha.Verify(validID, answer, false) {
|
||||
t.Fatal("known captcha was not stored")
|
||||
}
|
||||
login := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
|
||||
if login.Code != 200 || login.Token == "" {
|
||||
t.Fatalf("valid captcha login failed: code=%d msg=%q", login.Code, login.Msg)
|
||||
}
|
||||
replay := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
|
||||
if replay.Code == 200 {
|
||||
t.Fatal("used captcha was accepted again")
|
||||
}
|
||||
|
||||
wrongID := "bell-138-wrong"
|
||||
if err := base64Captcha.DefaultMemStore.Set(wrongID, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, "000000"), "")
|
||||
if wrong.Code == 200 {
|
||||
t.Fatal("incorrect captcha was accepted")
|
||||
}
|
||||
consumed := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, answer), "")
|
||||
if consumed.Code == 200 {
|
||||
t.Fatal("captcha used by a failed attempt was not consumed")
|
||||
}
|
||||
|
||||
unauthenticated := requestJSON(t, engine, http.MethodGet, "/api/v1/bell/alerts", nil, "")
|
||||
if unauthenticated.Code == 200 {
|
||||
t.Fatal("unauthenticated Bell business API was accepted")
|
||||
}
|
||||
disabled := httptest.NewRecorder()
|
||||
engine.ServeHTTP(disabled, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil))
|
||||
if disabled.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled default route returned HTTP %d", disabled.Code)
|
||||
}
|
||||
|
||||
assertSecretsAbsentFromAudit(t, password, answer, login.Token)
|
||||
assertSecretsAbsentFromLogs(t, password, answer, login.Token)
|
||||
if captcha.Verify(captchaResponse.ID, "deliberately-wrong", true) {
|
||||
t.Fatal("generated captcha accepted a deliberately incorrect answer")
|
||||
}
|
||||
}
|
||||
|
||||
func loginBody(username, password, id, answer string) map[string]string {
|
||||
return map[string]string{"username": username, "password": password, "uuid": id, "code": answer}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, engine http.Handler, method, path string, body any, token string) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
var response apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode %s response (HTTP %d): %v: %s", path, recorder.Code, err, recorder.Body.String())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func assertSecretsAbsentFromAudit(t *testing.T, secrets ...string) {
|
||||
t.Helper()
|
||||
db := sdk.Runtime.GetDbByKey("").Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
for _, table := range []string{"sys_login_log", "sys_opera_log"} {
|
||||
for _, secret := range secrets {
|
||||
var count int64
|
||||
query := "SELECT count(*) FROM " + table + " WHERE row_to_json(" + table + ")::text LIKE ?"
|
||||
if err := db.Raw(query, "%"+secret+"%").Scan(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("secret leaked into %s", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecretsAbsentFromLogs(t *testing.T, secrets ...string) {
|
||||
t.Helper()
|
||||
patterns := append([]string{"DriverDigitFunc answer:"}, secrets...)
|
||||
err := filepath.WalkDir("temp", func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern != "" && strings.Contains(string(content), pattern) {
|
||||
t.Fatalf("sensitive value found in server log %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$started = $false
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-138-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $root 'postgres'
|
||||
$log = Join-Path $root 'postgres.log'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForPort([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($connected) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw 'PostgreSQL did not start'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
$port = Get-FreePort
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) {
|
||||
throw "Missing $name"
|
||||
}
|
||||
}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
|
||||
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
|
||||
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
|
||||
Wait-ForPort $port
|
||||
$started = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_138
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_138 sslmode=disable"
|
||||
$env:BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_138_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = (Get-FreePort).ToString()
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
|
||||
go test ./tests/bell_production_login -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'production login test failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Output 'BELL_138_PRODUCTION_LOGIN captcha=200 valid_login=200 wrong_rejected=true replay_rejected=true secrets_absent=true'
|
||||
} finally {
|
||||
if ($started) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package bell_rule_alert_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/evaluation"
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
"go-admin/app/bell/rule"
|
||||
)
|
||||
|
||||
func TestPostgresRuleEvaluationAndAlertProjection(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_RULE_ALERT_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_RULE_ALERT_TEST_DATABASE_URL to run the isolated PostgreSQL test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
rules := rule.NewService(db)
|
||||
events := event.NewService(db)
|
||||
evaluations := evaluation.NewService(db)
|
||||
alerts := alert.NewService(db)
|
||||
assertOperatorAccess(t, db)
|
||||
createOperatorUser(t, db)
|
||||
|
||||
eventType := "danger_area_entered"
|
||||
location := "东门"
|
||||
first, err := rules.Create(ctx, rule.WriteInput{Code: "area-high", Name: "高风险区域", EventType: &eventType, MinimumSeverity: "high", LocationContains: &location}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := rules.Create(ctx, rule.WriteInput{Code: "all-high", Name: "全局高风险", MinimumSeverity: "high"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherEventType := "fire_detected"
|
||||
_, err = rules.Create(ctx, rule.WriteInput{Code: "critical-fire", Name: "仅严重火情", EventType: &otherEventType, MinimumSeverity: "critical"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created := ingest(t, events, "event-001", "东门 A 区", "high")
|
||||
result, err := evaluations.ForEvent(ctx, created.Event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Evaluations) != 3 || len(result.Alerts) != 2 {
|
||||
t.Fatalf("expected 3 evaluations and 2 alerts, got %d and %d", len(result.Evaluations), len(result.Alerts))
|
||||
}
|
||||
matched, unmatched := 0, 0
|
||||
for _, item := range result.Evaluations {
|
||||
if item.Matched {
|
||||
matched++
|
||||
} else if item.Explanation != "" {
|
||||
unmatched++
|
||||
}
|
||||
}
|
||||
if matched != 2 || unmatched != 1 {
|
||||
t.Fatalf("unexpected match explanations: matched=%d unmatched=%d", matched, unmatched)
|
||||
}
|
||||
|
||||
replay := ingest(t, events, "event-001", "东门 A 区", "high")
|
||||
if !replay.Duplicate || replay.Event.ID != created.Event.ID {
|
||||
t.Fatalf("idempotent replay created another fact: %#v", replay)
|
||||
}
|
||||
assertCount(t, db, "bell_rule_evaluations", 3)
|
||||
assertCount(t, db, "bell_alerts", 2)
|
||||
|
||||
secondEvent := ingest(t, events, "event-002", "东门 A 区", "critical")
|
||||
assertCount(t, db, "bell_alerts", 2)
|
||||
items, total, err := alerts.List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 20, Status: "open"})
|
||||
if err != nil || total != 2 || len(items) != 2 {
|
||||
t.Fatalf("unexpected alert list: total=%d len=%d err=%v", total, len(items), err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.EventCount != 2 || item.Status != "open" || item.Severity != "critical" {
|
||||
t.Fatalf("open alert did not aggregate and escalate: %#v", item)
|
||||
}
|
||||
detail, detailErr := alerts.Get(ctx, item.ID)
|
||||
if detailErr != nil || len(detail.Events) != 2 || len(detail.Matches) != 2 {
|
||||
t.Fatalf("event-alert navigation is incomplete: events=%d matches=%d err=%v", len(detail.Events), len(detail.Matches), detailErr)
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := rules.Update(ctx, first.ID, rule.WriteInput{Name: "高风险区域(更新)", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location}, 1)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("rule version was not incremented: version=%d err=%v", updated.Version, err)
|
||||
}
|
||||
if _, err = rules.SetEnabled(ctx, second.ID, false, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thirdEvent := ingest(t, events, "event-003", "东门 B 区", "medium")
|
||||
thirdResults, err := evaluations.ForEvent(ctx, thirdEvent.Event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(thirdResults.Evaluations) != 2 || len(thirdResults.Alerts) != 1 {
|
||||
t.Fatalf("disabled rule was evaluated: evaluations=%d alerts=%d", len(thirdResults.Evaluations), len(thirdResults.Alerts))
|
||||
}
|
||||
var snapshot struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
for _, item := range thirdResults.Evaluations {
|
||||
if item.RuleID == first.ID {
|
||||
if err = json.Unmarshal(item.RuleSnapshot, &snapshot); err != nil || item.RuleVersion != 2 || snapshot.Version != 2 {
|
||||
t.Fatalf("versioned rule snapshot missing: item=%#v snapshot=%#v err=%v", item, snapshot, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = db.Model(&evaluation.Evaluation{}).Where("event_id = ? AND rule_id = ?", created.Event.ID, first.ID).Update("explanation", "tampered").Error; err == nil {
|
||||
t.Fatal("immutable evaluation update unexpectedly succeeded")
|
||||
}
|
||||
|
||||
if err = db.Exec(`CREATE FUNCTION bell_test_reject_alert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced alert failure'; END $$`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TRIGGER bell_test_reject_alert BEFORE INSERT ON bell_alerts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_alert()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failedID := "event-rollback"
|
||||
_, ingestErr := events.Ingest(ctx, eventCommand(failedID, "东门 C 区", "high"), 1)
|
||||
if ingestErr == nil {
|
||||
t.Fatal("forced alert failure did not roll back Event ingest")
|
||||
}
|
||||
var eventCount, receiptCount int64
|
||||
db.Model(&event.Event{}).Where("source_event_id = ?", failedID).Count(&eventCount)
|
||||
db.Model(&receipt.Receipt{}).Where("source_event_id = ?", failedID).Count(&receiptCount)
|
||||
if eventCount != 0 || receiptCount != 0 {
|
||||
t.Fatalf("transaction failure left partial facts: events=%d receipts=%d", eventCount, receiptCount)
|
||||
}
|
||||
if err = db.Exec(`DROP TRIGGER bell_test_reject_alert ON bell_alerts; DROP FUNCTION bell_test_reject_alert()`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if secondEvent.Event.ID == thirdEvent.Event.ID {
|
||||
t.Fatal("independent Events unexpectedly share an id")
|
||||
}
|
||||
}
|
||||
|
||||
func ingest(t *testing.T, service event.Service, sourceID, location, severity string) event.Result {
|
||||
t.Helper()
|
||||
result, err := service.Ingest(context.Background(), eventCommand(sourceID, location, severity), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func eventCommand(sourceID, location, severity string) event.Command {
|
||||
return event.Command{ProducerID: "bell.rule-test", SourceEventID: sourceID, EventType: "danger_area_entered", OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: location, Severity: severity, Attributes: map[string]any{"test": true}}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, db *gorm.DB, table string, want int64) {
|
||||
t.Helper()
|
||||
var got int64
|
||||
if err := db.Table(table).Count(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatal(fmt.Sprintf("%s count: got %d want %d", table, got, want))
|
||||
}
|
||||
}
|
||||
|
||||
func assertOperatorAccess(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount int64
|
||||
if err := db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id = rm.role_id").
|
||||
Joins("JOIN sys_menu m ON m.menu_id = rm.menu_id").
|
||||
Where("r.role_key = ? AND m.path IN ?", "operator", []string{"/bell", "alerts", "events", "rules"}).Count(&menuCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ? AND v1 LIKE ?", "operator", "GET", "/api/v1/bell/rules%").Count(&ruleWritePolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ? AND v1 IN ?", "operator", "POST", []string{"/api/v1/bell/alerts/:id/ack", "/api/v1/bell/alerts/:id/close"}).Count(&lifecycleWritePolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if menuCount != 4 || readPolicyCount < 6 || ruleWritePolicyCount != 0 || lifecycleWritePolicyCount > 2 {
|
||||
t.Fatalf("operator access escaped Bell scope: menus=%d reads=%d rule_writes=%d lifecycle_writes=%d", menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func createOperatorUser(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
|
||||
if password == "" {
|
||||
t.Skip("set BELL_RULE_ALERT_OPERATOR_PASSWORD for the HTTP RBAC continuation")
|
||||
}
|
||||
var roleID int
|
||||
if err := db.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("load operator role: id=%d err=%v", roleID, err)
|
||||
}
|
||||
user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pgStarted = $false
|
||||
$server = $null
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-132-' + [guid]::NewGuid().ToString('N'))
|
||||
$pgData = Join-Path $testRoot 'postgres'
|
||||
$pgLog = Join-Path $testRoot 'postgres.log'
|
||||
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
|
||||
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
|
||||
$serverOut = Join-Path $testRoot 'bell.out.log'
|
||||
$serverErr = Join-Path $testRoot 'bell.err.log'
|
||||
$serverExe = Join-Path $testRoot 'bell-server.exe'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
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 Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
$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 ($attempt = 0; $attempt -lt 100; $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 endpoint did not become ready'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreeTcpPort
|
||||
$bellPort = Get-FreeTcpPort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
$database = 'bell_132'
|
||||
|
||||
try {
|
||||
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
$path = Join-Path $PostgresBin $required
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
|
||||
}
|
||||
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -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 $pgCtlOut -RedirectStandardError $pgCtlErr -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 postgres $database
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
|
||||
$env:BELL_RULE_ALERT_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_132_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = $bellPort.ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "Bell migration failed; see $(Join-Path $testRoot 'migrate.log')" }
|
||||
go test ./tests/bell_rule_alert -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell rule-alert integration test failed' }
|
||||
go build -o $serverExe .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $baseUrl
|
||||
|
||||
$unauthorized = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/rules" -TimeoutSec 5 -NoProxy
|
||||
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated rule list returned code $($unauthorized.code)" }
|
||||
|
||||
$adminLoginBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$adminLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminLoginBody -TimeoutSec 5 -NoProxy
|
||||
if ([int]$adminLogin.code -ne 200) { throw 'Bell administrator login failed' }
|
||||
$adminHeaders = @{ Authorization = "Bearer $($adminLogin.token)" }
|
||||
$adminRule = @{ code = 'http-admin'; name = '管理员 HTTP 规则'; eventType = $null; minimumSeverity = 'high'; locationContains = $null } | ConvertTo-Json -Compress
|
||||
$adminWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $adminRule -TimeoutSec 5 -NoProxy
|
||||
if ([int]$adminWrite.code -ne 200 -or [int]$adminWrite.data.version -ne 1) { throw 'administrator rule create failed' }
|
||||
|
||||
$operatorLoginBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$operatorLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorLoginBody -TimeoutSec 5 -NoProxy
|
||||
if ([int]$operatorLogin.code -ne 200) { throw 'Bell operator login failed' }
|
||||
$operatorHeaders = @{ Authorization = "Bearer $($operatorLogin.token)" }
|
||||
foreach ($path in @('/api/v1/bell/rules','/api/v1/bell/events','/api/v1/bell/alerts')) {
|
||||
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
|
||||
if ([int]$read.code -ne 200) { throw "operator read $path returned code $($read.code)" }
|
||||
}
|
||||
$operatorWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $operatorHeaders -ContentType 'application/json' -Body $adminRule -TimeoutSec 5 -NoProxy
|
||||
if ([int]$operatorWrite.code -ne 403) { throw "operator rule write returned code $($operatorWrite.code)" }
|
||||
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
|
||||
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
|
||||
foreach ($title in @('预警中心','预警管理','事件查询','规则配置')) {
|
||||
if (-not $menuJson.Contains($title)) { throw "operator menu is missing $title" }
|
||||
}
|
||||
if ($menuJson.Contains('系统管理') -or $menuJson.Contains('开发工具')) { throw 'operator menu exposed unrelated GoAdmin modules' }
|
||||
Write-Output 'BELL_132_HTTP unauthenticated=401 admin_write=200 operator_reads=200 operator_write=403 minimal_menu=true'
|
||||
} finally {
|
||||
if ($null -ne $server -and -not $server.HasExited) {
|
||||
Stop-Process -Id $server.Id -Force
|
||||
$server.WaitForExit(5000) | Out-Null
|
||||
}
|
||||
if ($pgStarted) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL','BELL_RULE_ALERT_TEST_DATABASE_URL','BELL_RULE_ALERT_OPERATOR_PASSWORD','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Verbose "Bell #132 temporary artifacts: $testRoot"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package bell_rule_alert_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/rule"
|
||||
)
|
||||
|
||||
func TestRuleNormalizeTrimsAndNormalizesFields(t *testing.T) {
|
||||
eventType := " danger_area_entered "
|
||||
location := " 东门 "
|
||||
got, err := rule.Normalize(rule.WriteInput{
|
||||
Code: " AREA_HIGH ", Name: " 高风险区域 ", EventType: &eventType,
|
||||
MinimumSeverity: " HIGH ", LocationContains: &location,
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Code != "area_high" || got.Name != "高风险区域" || got.MinimumSeverity != "high" {
|
||||
t.Fatalf("unexpected normalized rule: %#v", got)
|
||||
}
|
||||
if got.EventType == nil || *got.EventType != "danger_area_entered" || got.LocationContains == nil || *got.LocationContains != "东门" {
|
||||
t.Fatalf("optional fields were not normalized: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleNormalizeRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input rule.WriteInput
|
||||
}{
|
||||
{name: "invalid code", input: rule.WriteInput{Code: "Bad Code", Name: "规则", MinimumSeverity: "low"}},
|
||||
{name: "missing name", input: rule.WriteInput{Code: "valid-code", Name: " ", MinimumSeverity: "low"}},
|
||||
{name: "unknown severity", input: rule.WriteInput{Code: "valid-code", Name: "规则", MinimumSeverity: "urgent"}},
|
||||
{name: "control character", input: rule.WriteInput{Code: "valid-code", Name: "规则\n泄露", MinimumSeverity: "low"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := rule.Normalize(test.input, true); !errors.Is(err, rule.ErrInvalid) {
|
||||
t.Fatalf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleUpdateKeepsImmutableCodeOutsideInput(t *testing.T) {
|
||||
got, err := rule.Normalize(rule.WriteInput{Code: "ignored invalid code", Name: "更新后规则", MinimumSeverity: "medium"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "更新后规则" || got.MinimumSeverity != "medium" {
|
||||
t.Fatalf("unexpected update normalization: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package event_ingress_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
func TestPostgresConcurrentBusinessAndSecurityIdempotency(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_EVENT_INGRESS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_EVENT_INGRESS_TEST_DATABASE_URL to run PostgreSQL concurrency verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := event_ingress.ParseEvent(fixture(t, "dangerous-area.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers = 12
|
||||
var created, duplicate, failures atomic.Int32
|
||||
var wait sync.WaitGroup
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
result, ingestErr := (event_ingress.Service{DB: db}).Ingest(context.Background(), parsed)
|
||||
if ingestErr != nil {
|
||||
failures.Add(1)
|
||||
return
|
||||
}
|
||||
if result.Disposition == "created" {
|
||||
created.Add(1)
|
||||
} else if result.Disposition == "duplicate" {
|
||||
duplicate.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if created.Load() != 1 || duplicate.Load() != workers-1 || failures.Load() != 0 {
|
||||
t.Fatalf("concurrent ingest created=%d duplicate=%d failures=%d", created.Load(), duplicate.Load(), failures.Load())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var consumed atomic.Int32
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if (event_ingress.PersistentReplayStore{DB: db}).Consume("yv:sense:school-a", "concurrent-token-id-0001", now.Add(time.Minute), now) {
|
||||
consumed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if consumed.Load() != 1 {
|
||||
t.Fatalf("concurrent replay consume accepted %d requests", consumed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRegistrationIsOptionalAndMigrationGated(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
|
||||
disabled := gin.New()
|
||||
if err := event_ingress.RegisterRuntime(disabled); err != nil || len(disabled.Routes()) != 0 {
|
||||
t.Fatalf("disabled runtime err=%v routes=%#v", err, disabled.Routes())
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:bell-runtime?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sdk.Runtime.SetDb("", db)
|
||||
t.Cleanup(func() { sdk.Runtime.SetDb("", nil) })
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
|
||||
if err = event_ingress.RegisterRuntime(gin.New()); err == nil {
|
||||
t.Fatal("enabled runtime started without formal migration")
|
||||
}
|
||||
if err = db.AutoMigrate(&event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryPath := writeRegistry(t, "yovision-bell", "yv:sense:school-a", "sense-key-0001")
|
||||
t.Setenv("BELL_MACHINE_PRINCIPAL_REGISTRY", registryPath)
|
||||
registered := gin.New()
|
||||
if err = event_ingress.RegisterRuntime(registered); err != nil {
|
||||
t.Fatalf("enabled runtime did not register after migration: %v", err)
|
||||
}
|
||||
routes := registered.Routes()
|
||||
if len(routes) != 1 || routes[0].Method != http.MethodPost || routes[0].Path != "/v1/events" {
|
||||
t.Fatalf("unexpected ingress routes: %#v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractFixtureIdempotencyConflictAndReplayPersistence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
databasePath := filepath.Join(t.TempDir(), "bell-ingress.sqlite")
|
||||
db := openDatabasePath(t, databasePath)
|
||||
body := fixture(t, "dangerous-area.json")
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
newHandler := func() event_ingress.Handler {
|
||||
return event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
}
|
||||
|
||||
firstToken := mint(t, signer, body)
|
||||
first := request(t, newHandler(), body, firstToken)
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first ingest status=%d body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
var created event_ingress.IngestResult
|
||||
decode(t, first, &created)
|
||||
if created.Disposition != "created" || created.PayloadSHA256 != "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1" {
|
||||
t.Fatalf("unexpected created result: %+v", created)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sqlDatabase.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = openDatabasePath(t, databasePath)
|
||||
|
||||
// A new process-local Handler and replay store still reject the old token,
|
||||
// proving that security replay state is durable rather than in-memory.
|
||||
replayedToken := request(t, newHandler(), body, firstToken)
|
||||
if replayedToken.Code != http.StatusUnauthorized || !strings.Contains(replayedToken.Body.String(), "machine_token_replayed") {
|
||||
t.Fatalf("token replay status=%d body=%s", replayedToken.Code, replayedToken.Body.String())
|
||||
}
|
||||
|
||||
duplicate := request(t, newHandler(), body, mint(t, signer, body))
|
||||
if duplicate.Code != http.StatusOK {
|
||||
t.Fatalf("business duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
var duplicateResult event_ingress.IngestResult
|
||||
decode(t, duplicate, &duplicateResult)
|
||||
if duplicateResult.Disposition != "duplicate" || duplicateResult.EventID != created.EventID {
|
||||
t.Fatalf("duplicate did not retain event identity: %+v", duplicateResult)
|
||||
}
|
||||
numericVariant := bytes.Replace(body, []byte(`0.93`), []byte(`0.930`), 1)
|
||||
numericDuplicate := request(t, newHandler(), numericVariant, mint(t, signer, numericVariant))
|
||||
if numericDuplicate.Code != http.StatusOK || !strings.Contains(numericDuplicate.Body.String(), created.PayloadSHA256) {
|
||||
t.Fatalf("JCS-equivalent numeric payload was not a duplicate: %d %s", numericDuplicate.Code, numericDuplicate.Body.String())
|
||||
}
|
||||
|
||||
var changed map[string]any
|
||||
if err = json.Unmarshal(body, &changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed["severity"] = "critical"
|
||||
conflicting, _ := json.Marshal(changed)
|
||||
conflict := request(t, newHandler(), conflicting, mint(t, signer, conflicting))
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "idempotency_conflict") || !strings.Contains(conflict.Body.String(), created.EventID) {
|
||||
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 1)
|
||||
assertCount(t, db, &receipt.Receipt{}, 1)
|
||||
assertCount(t, db, &receipt.IngestAudit{}, 4)
|
||||
}
|
||||
|
||||
func TestEvidenceDegradationIdentityErrorsAndDisabledConnector(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
registry, _ := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
handler := event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
|
||||
pending := fixture(t, "dangerous-area.json")
|
||||
if response := requestWithID(t, handler, pending, mint(t, signer, pending), "short"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request_id") {
|
||||
t.Fatalf("invalid request id status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
missingRequestID := requestWithID(t, handler, pending, mint(t, signer, pending), "")
|
||||
if missingRequestID.Code != http.StatusCreated || !requestIDPatternForTest(missingRequestID.Header().Get("X-Request-ID")) {
|
||||
t.Fatalf("trusted hop did not create a request id: %d %s", missingRequestID.Code, missingRequestID.Body.String())
|
||||
}
|
||||
queryResponse := requestTarget(t, handler, pending, mint(t, signer, pending), "/v1/events?debug=true")
|
||||
if queryResponse.Code != http.StatusBadRequest || !strings.Contains(queryResponse.Body.String(), "invalid_request_target") {
|
||||
t.Fatalf("query target was accepted: %d %s", queryResponse.Code, queryResponse.Body.String())
|
||||
}
|
||||
if response := request(t, handler, pending, mint(t, signer, pending)); response.Code != http.StatusOK {
|
||||
t.Fatalf("pending evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
failed := fixture(t, "directional-line-crossed.json")
|
||||
if response := request(t, handler, failed, mint(t, signer, failed)); response.Code != http.StatusCreated {
|
||||
t.Fatalf("failed evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 2)
|
||||
|
||||
wrongAudienceToken, err := signer.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", pending)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response := request(t, handler, pending, wrongAudienceToken); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong audience was not forbidden: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response := request(t, event_ingress.Handler{Enabled: false}, pending, "none"); response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("disabled connector status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceResolverCurrentMissingExpiredAndTimeout(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
status := event_ingress.EvidenceStatus{EventID: "event-1", EvidenceID: "ev-school-east-0001", OwnerID: "sense-school-a", Status: "pending", Resolution: "snapshot", CurrentPayload: json.RawMessage(`{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"pending","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:01.125Z"}`), CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&status).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
response := func(code int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
client := event_ingress.EvidenceClient{Endpoint: "https://sense.example", Signer: signer, HTTP: doFunc(func(*http.Request) (*http.Response, error) {
|
||||
return response(http.StatusNotFound, `{}`), nil
|
||||
})}
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_not_found" {
|
||||
t.Fatalf("missing resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusGone, `{}`), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "expired" {
|
||||
t.Fatalf("expired resolution=%s db=%v", status.Resolution, err)
|
||||
}
|
||||
current := `{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"success","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:02.125Z","content_type":"image/jpeg","integrity":{"algorithm":"sha256","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1}}`
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, current), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "current" || status.Status != "success" || status.LastError != "" {
|
||||
t.Fatalf("current status=%s resolution=%s error=%s db=%v", status.Status, status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_timeout" {
|
||||
t.Fatalf("timeout resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *gorm.DB {
|
||||
return openDatabasePath(t, filepath.Join(t.TempDir(), "bell-ingress.sqlite"))
|
||||
}
|
||||
|
||||
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", name)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeRegistry(t *testing.T, audience, principal, keyID string) string {
|
||||
t.Helper()
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": audience,
|
||||
"principals": []any{map[string]any{
|
||||
"principal_id": principal, "enabled": true,
|
||||
"keys": []any{map[string]any{
|
||||
"kid": keyID, "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
"status": "active", "scopes": []string{"events:ingest"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "registry.json")
|
||||
if err = os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mint(t *testing.T, signer machine_identity.Signer, body []byte) string {
|
||||
t.Helper()
|
||||
token, err := signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler event_ingress.Handler, body []byte, token string) *httptest.ResponseRecorder {
|
||||
return requestWithID(t, handler, body, token, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, requestID string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, "/v1/events", requestID)
|
||||
}
|
||||
|
||||
func requestTarget(t *testing.T, handler event_ingress.Handler, body []byte, token, target string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, target, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestTargetWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, target, requestID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
handler.Post(context)
|
||||
return response
|
||||
}
|
||||
|
||||
func requestIDPatternForTest(value string) bool {
|
||||
if len(value) < 16 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || index > 0 && strings.ContainsRune("._:-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decode(t *testing.T, response *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, db *gorm.DB, model any, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil || count != expected {
|
||||
t.Fatalf("count %T=%d expected=%d err=%v", model, count, expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
type doFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function doFunc) Do(request *http.Request) (*http.Response, error) { return function(request) }
|
||||
@@ -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.'
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user