From f1c765f8cc8f9dc57a825dbb3b16141f931d5c9b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 18:56:59 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20Sense=20=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=9B=9E=E5=BD=92=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/ACCEPTANCE.md | 46 +++ .../compatibility/run-static-regression.ps1 | 52 +++ Sense/tests/e2e/browser-smoke.cjs | 203 +++++++++++ Sense/tests/e2e/run-isolated-e2e.ps1 | 327 ++++++++++++++++++ Sense/tests/fixtures/area-polygon.zh-CN.json | 13 + Sense/tests/fixtures/device-create.zh-CN.json | 8 + Sense/tests/fixtures/onvif_digest_fixture.py | 121 +++++++ 7 files changed, 770 insertions(+) create mode 100644 Sense/ACCEPTANCE.md create mode 100644 Sense/tests/compatibility/run-static-regression.ps1 create mode 100644 Sense/tests/e2e/browser-smoke.cjs create mode 100644 Sense/tests/e2e/run-isolated-e2e.ps1 create mode 100644 Sense/tests/fixtures/area-polygon.zh-CN.json create mode 100644 Sense/tests/fixtures/device-create.zh-CN.json create mode 100644 Sense/tests/fixtures/onvif_digest_fixture.py diff --git a/Sense/ACCEPTANCE.md b/Sense/ACCEPTANCE.md new file mode 100644 index 0000000..b3aa55b --- /dev/null +++ b/Sense/ACCEPTANCE.md @@ -0,0 +1,46 @@ +# Sense 独立纵切验收 + +本验收只启动 Sense、临时 PostgreSQL 17、测试用 ONVIF/RTSP 夹具和独立 MediaMTX;不启动 Brain 或 Bell。测试凭据在每次运行时随机生成,只通过子进程环境和系统临时目录传递,测试结束后清理。 + +## 自动化入口 + +从仓库根目录执行: + +```powershell +& Sense\tests\compatibility\run-static-regression.ps1 +& Sense\tests\e2e\run-isolated-e2e.ps1 +``` + +隔离 E2E 默认使用 `D:\pgsql17\bin`、已审核的 `C:\Users\ila20\Desktop\mediamtx\mediamtx.exe` 和本机 Chrome。路径不同时使用参数显式指定。运行数据、数据库、构建副本、浏览器截图和日志全部位于系统临时目录;只有排错时才使用 `-KeepTemporary`,其中可能含运行时秘密,必须按敏感数据保护并及时清理。 + +E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。 + +## 回归矩阵 + +| 范围 | 自动化证据 | 判定 | +|---|---|---| +| GoAdmin 来源与复用 | 检查冻结 commit、MIT 文件、Cobra、迁移、Gin Router、JWT、动态 Router/Store/Axios/Layout/权限入口 | 必须通过 | +| 空白 PostgreSQL 17 | 临时集群执行完整迁移,核对迁移版本和 `capabilities` JSONB | 必须通过 | +| 登录与 RBAC | 一次性安全初始化、免验证码登录、未认证访问拒绝、管理员权限链 | 必须通过 | +| 中文与请求白名单 | 中文设备/安装位置/区域往返;未知凭据字段返回 400 | 必须通过 | +| 凭据边界 | ONVIF/RTSP 分用途写入;API、媒体路径、日志和数据库均不出现明文 | 必须通过 | +| ONVIF 与 RTSP | 测试 ONVIF 对每个 SOAP 操作强制并校验 Digest;RTSP OPTIONS 验证合成源 | 必须通过 | +| MediaMTX | 独立端口、回环 Control API、按需路径、合成视频消费者和安全播放地址 | 必须通过 | +| 实时监看 | 创建短期播放会话并验证同源 wrapper 与浏览器可达地址 | 必须通过 | +| 区域配置 | 多边形保存;Profile 分辨率变化后自动标记需要重校准 | 必须通过 | +| GoAdmin UI 外壳 | Chrome 验证侧栏、顶部导航、标签页和五个 Sense 页面;无无关入口 | 必须通过 | +| Windows 交付 | 临时副本按固定 Go/Node/pnpm 构建,生产启动、停止、冷启动路径恢复 | 必须通过 | +| 独立性 | 测试不连接 Brain/Bell,不写 `contracts/` 或根级部署 | 必须通过 | + +## 历史迁移裁决 + +- 旧 `explore` 只提供需求与故障清单,不作为通过证据,也不复制其自研认证、RBAC、HTTP 外壳或业务实现。 +- 中文绑定、严格请求字段、Digest、凭据分离、Media 回环地址、按需拉流、冷启动恢复、区域重校准和 Windows 包必须由当前 `dev` 新基线重新运行。 +- 测试失败时保留失败证据并建立独立缺陷工单;本验收任务不修改产品实现以掩盖失败。 + +## 未验证边界 + +- 客户现场真实 ONVIF 摄像机型号、固件差异、网络丢包和时钟偏差。 +- 客户全新 Windows 主机、生产数据库账号权限、防火墙和服务管理器。 +- 16 路及更高并发、长时间稳定性、硬件解码与实际带宽;16 路只是默认交付配额,不是代码硬上限。 +- Brain/Bell 契约、跨项目端到端事件和证据链,必须由后续协调工单验证。 diff --git a/Sense/tests/compatibility/run-static-regression.ps1 b/Sense/tests/compatibility/run-static-regression.ps1 new file mode 100644 index 0000000..1c1f89a --- /dev/null +++ b/Sense/tests/compatibility/run-static-regression.ps1 @@ -0,0 +1,52 @@ +param([string]$RepositoryRoot = '') +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) +} +$senseRoot = Join-Path $RepositoryRoot 'Sense' +$passed = 0 +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw "ASSERT FAILED: $Message" } + $script:passed++ +} +function Read-Utf8([string]$Path) { return [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) } + +$baseline = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'goadmin-baseline.json') -Raw -Encoding utf8 | ConvertFrom-Json +Assert-True ($baseline.sources.'go-admin'.commit -eq 'f06540883b41d03782bb6b2c4150f298f328c6b6') 'Sense backend baseline drifted' +Assert-True ($baseline.sources.'go-admin-ui'.commit -eq '67d393d713877572fab0b897296a4c1d525fc81d') 'Sense UI baseline drifted' +Assert-True (Test-Path (Join-Path $senseRoot 'server\LICENSE.md')) 'go-admin MIT license is missing' +Assert-True (Test-Path (Join-Path $senseRoot 'ui\LICENSE')) 'go-admin-ui MIT license is missing' + +$requiredBackend = @( + 'server\cmd\cobra.go', 'server\common\middleware\auth.go', + 'server\app\admin\router\router.go', 'server\cmd\migrate\migration\init.go' +) +foreach ($relative in $requiredBackend) { + Assert-True (Test-Path (Join-Path $senseRoot $relative)) "GoAdmin backend path missing: $relative" +} +$requiredFrontend = @( + 'ui\src\router\index.js', 'ui\src\store\index.js', + 'ui\src\store\modules\permission.js', 'ui\src\utils\request.js', + 'ui\src\layout\index.vue', 'ui\src\directive\permission\permission.js' +) +foreach ($relative in $requiredFrontend) { + Assert-True (Test-Path (Join-Path $senseRoot $relative)) "go-admin-ui path missing: $relative" +} + +$router = Read-Utf8 (Join-Path $senseRoot 'ui\src\store\modules\permission.js') +Assert-True ($router.Contains("item.component === 'Layout' ? Layout")) 'dynamic routes no longer preserve GoAdmin Layout' +$request = Read-Utf8 (Join-Path $senseRoot 'ui\src\utils\request.js') +Assert-True ($request.Contains("Authorization") -and $request.Contains("axios.create")) 'Axios/JWT request chain is missing' +$auth = Read-Utf8 (Join-Path $senseRoot 'server\common\middleware\auth.go') +Assert-True ($auth.Contains('jwt.New') -and $auth.Contains('sense_session')) 'GoAdmin JWT chain is missing' + +$fixtureFiles = Get-ChildItem -LiteralPath (Join-Path $senseRoot 'tests\fixtures') -Recurse -File +foreach ($file in $fixtureFiles) { + $content = Read-Utf8 $file.FullName + Assert-True ($content -notmatch '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') "fixture contains a forbidden credential marker: $($file.Name)" + if ($file.Extension -eq '.json') { [void]($content | ConvertFrom-Json); $passed++ } +} + +Write-Host "Sense compatibility regression passed: $passed assertions." diff --git a/Sense/tests/e2e/browser-smoke.cjs b/Sense/tests/e2e/browser-smoke.cjs new file mode 100644 index 0000000..66e36a1 --- /dev/null +++ b/Sense/tests/e2e/browser-smoke.cjs @@ -0,0 +1,203 @@ +'use strict' + +const fs = require('fs') +const net = require('net') +const os = require('os') +const path = require('path') +const { spawn } = require('child_process') + +function required(name) { + const value = process.env[name] + if (!value) throw new Error(`${name} is required`) + return value +} + +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)) + +async function freePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const port = server.address().port + server.close(() => resolve(port)) + }) + }) +} + +async function retry(operation, label, attempts = 80) { + let lastError + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { return await operation() } catch (error) { lastError = error } + await delay(250) + } + throw new Error(`${label}: ${lastError ? lastError.message : 'timed out'}`) +} + +class PageSession { + constructor(socketURL, failures) { + this.socket = new WebSocket(socketURL) + this.failures = failures + this.nextID = 1 + this.pending = new Map() + } + + async open() { + await new Promise((resolve, reject) => { + this.socket.addEventListener('open', resolve, { once: true }) + this.socket.addEventListener('error', reject, { once: true }) + }) + this.socket.addEventListener('message', event => { + const message = JSON.parse(event.data) + if (message.id) { + const pending = this.pending.get(message.id) + if (!pending) return + this.pending.delete(message.id) + if (message.error) pending.reject(new Error(message.error.message)) + else pending.resolve(message.result || {}) + return + } + if (message.method === 'Runtime.exceptionThrown') { + this.failures.push(`page: ${message.params.exceptionDetails.text}`) + } + if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') { + const text = message.params.args.map(item => item.value || item.description || '').join(' ') + if (!text.includes('favicon')) this.failures.push(`console: ${text}`) + } + if (message.method === 'Network.responseReceived') { + const response = message.params.response + if (response.status >= 400 && !response.url.includes('favicon')) { + this.failures.push(`http ${response.status}: ${response.url}`) + } + } + }) + await Promise.all([ + this.send('Page.enable'), this.send('Runtime.enable'), this.send('Network.enable') + ]) + await this.send('Emulation.setDeviceMetricsOverride', { + width: 1440, height: 900, deviceScaleFactor: 1, mobile: false + }) + } + + send(method, params = {}) { + const id = this.nextID++ + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + async evaluate(expression) { + const result = await this.send('Runtime.evaluate', { + expression, returnByValue: true, awaitPromise: true + }) + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text) + return result.result.value + } + + async navigate(url) { + await this.send('Page.navigate', { url }) + await retry(async () => { + if (await this.evaluate('document.readyState') !== 'complete') throw new Error('document not ready') + }, `navigate ${url}`) + } + + async waitFor(expression, label) { + return await retry(async () => { + const value = await this.evaluate(expression) + if (!value) throw new Error('condition is false') + return value + }, label) + } + + close() { this.socket.close() } +} + +async function createPage(debugPort, failures) { + const target = await retry(async () => { + const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: 'PUT' }) + if (!response.ok) throw new Error(`Chrome target returned ${response.status}`) + return await response.json() + }, 'create Chrome target') + const page = new PageSession(target.webSocketDebuggerUrl, failures) + await page.open() + return page +} + +async function main() { + const baseURL = required('SENSE_E2E_BASE_URL') + const token = required('SENSE_E2E_TOKEN') + const screenshot = required('SENSE_E2E_SCREENSHOT') + const debugPort = await freePort() + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'sense-chrome-')) + const chrome = spawn(required('SENSE_E2E_BROWSER'), [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, 'about:blank' + ], { stdio: 'ignore', windowsHide: true }) + const failures = [] + try { + const anonymous = await createPage(debugPort, failures) + await anonymous.navigate(baseURL) + await anonymous.waitFor('location.hash.includes("login")', 'anonymous redirect to login') + await anonymous.waitFor('!!document.querySelector(\'input[name="username"]\')', 'username input') + if (await anonymous.evaluate('[...document.querySelectorAll("input")].some(input => input.placeholder.includes("验证码"))')) { + failures.push('login page unexpectedly exposes a captcha input') + } + anonymous.close() + + const page = await createPage(debugPort, failures) + await page.send('Network.setCookie', { name: 'Sense-Admin-Token', value: token, url: baseURL }) + await page.navigate(`${baseURL}/#/dashboard`) + await page.waitFor('!!document.querySelector(".sidebar-container")', 'GoAdmin sidebar') + await page.waitFor('!!document.querySelector(".navbar")', 'GoAdmin navbar') + await page.waitFor('!!document.querySelector(".tags-view-container")', 'GoAdmin tags') + await page.evaluate(`(() => { + const title = [...document.querySelectorAll('.el-sub-menu__title')] + .find(item => item.innerText.includes('视频感知')) + if (title) title.click() + })()`) + await delay(300) + + const expected = [ + ['设备管理', '/sense/device'], + ['视频接入', '/sense/admission'], + ['视频服务', '/sense/media'], + ['实时监看', '/sense/liveview'], + ['区域与警戒线', '/sense/area'] + ] + const sidebarText = await page.evaluate('document.querySelector(".sidebar-container").innerText') + for (const [label, route] of expected) { + if (!sidebarText.includes(label)) failures.push(`missing menu: ${label}`) + const clicked = await page.evaluate(`(() => { + const item = [...document.querySelectorAll('.sidebar-container .el-menu-item')] + .find(node => node.innerText.trim() === ${JSON.stringify(label)}) + if (!item) return false + item.click() + return true + })()`) + if (!clicked) failures.push(`menu is not clickable: ${label}`) + else await page.waitFor(`location.hash.includes(${JSON.stringify(route)})`, `route ${route}`) + if (!await page.evaluate('!!document.querySelector(".sidebar-container")')) { + failures.push(`GoAdmin shell disappeared after ${label}`) + } + } + for (const label of ['开发工具', '定时任务', '系统监控']) { + if (sidebarText.includes(label)) failures.push(`unrelated menu is visible: ${label}`) + } + const captured = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }) + fs.mkdirSync(path.dirname(screenshot), { recursive: true }) + fs.writeFileSync(screenshot, captured.data, 'base64') + page.close() + } finally { + chrome.kill() + try { fs.rmSync(profile, { recursive: true, force: true }) } catch {} + } + if (failures.length) throw new Error([...new Set(failures)].join('\n')) + console.log('Sense browser smoke passed: login, GoAdmin shell, five product routes, no unrelated menus.') +} + +main().catch(error => { + console.error(error.message) + process.exitCode = 1 +}) diff --git a/Sense/tests/e2e/run-isolated-e2e.ps1 b/Sense/tests/e2e/run-isolated-e2e.ps1 new file mode 100644 index 0000000..174fb1b --- /dev/null +++ b/Sense/tests/e2e/run-isolated-e2e.ps1 @@ -0,0 +1,327 @@ +param( + [string]$PostgresBin = 'D:\pgsql17\bin', + [string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe', + [string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe', + [string]$PreparedPackageRoot = '', + [switch]$KeepTemporary +) +if ($PSVersionTable.PSEdition -eq 'Core') { + $legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser) + if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) } + if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' } + & powershell.exe @legacyArguments + exit $LASTEXITCODE +} +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) +$sourceSense = Join-Path $repositoryRoot 'Sense' +$temporary = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-' + [guid]::NewGuid().ToString('N')) +$repoCopy = Join-Path $temporary 'repo' +$senseCopy = Join-Path $repoCopy 'Sense' +$pgData = Join-Path $temporary 'postgres' +$pgLog = Join-Path $temporary 'postgres.log' +$pgCtlLog = Join-Path $temporary 'pg-ctl.log' +$runtimeLog = Join-Path $temporary 'sense.out.log' +$runtimeError = Join-Path $temporary 'sense.err.log' +$fixtureLog = Join-Path $temporary 'fixture.out.log' +$fixtureError = Join-Path $temporary 'fixture.err.log' +$ffmpegLog = Join-Path $temporary 'ffmpeg.out.log' +$ffmpegError = Join-Path $temporary 'ffmpeg.err.log' +$stateFile = Join-Path $temporary 'profile-state.txt' +$fixtureStatus = Join-Path $temporary 'fixture-status.json' +$server = $null +$fixture = $null +$publisher = $null +$pgStarted = $false +$savedEnvironment = @{} + +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 Get-UniqueFreePorts([int]$Count) { + $ports = New-Object System.Collections.Generic.List[int] + while ($ports.Count -lt $Count) { + $candidate = Get-FreePort + if (-not $ports.Contains($candidate)) { $ports.Add($candidate) } + } + 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-Http([string]$Uri, [int]$Attempts = 120) { + for ($attempt = 0; $attempt -lt $Attempts; $attempt++) { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1 + if ($response.StatusCode -eq 200) { return } + } catch {} + Start-Sleep -Milliseconds 500 + } + throw "HTTP endpoint did not become ready: $Uri" +} +function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) { + for ($attempt = 0; $attempt -lt $Attempts; $attempt++) { + $client = [Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync('127.0.0.1', $Port) + $connected = $task.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 expected open=$Open state" +} +function Invoke-SenseJson { + 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 = 15 } + 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 { + $safe = $_.Exception.Message -replace '(?i)(password|token)=[^\s;]+', '$1=' + throw "Sense request failed for $Method $Path`: $safe" + } + if ([int]$response.code -ne $ExpectedCode) { + throw "Unexpected Sense code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)" + } + return $response +} +function Start-SensePackage([string]$PackageRoot) { + $launcher = Join-Path $PackageRoot 'start-sense.bat' + $process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru + Wait-Http "$script:baseUrl/" + return $process +} +function Stop-ProcessTree($Process) { + if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null } +} + +try { + foreach ($required in @( + (Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'), + (Join-Path $PostgresBin 'createdb.exe'), (Join-Path $PostgresBin 'psql.exe'), + $MediaMTX, $Browser + )) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required test dependency not found: $required" } + } + $ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source + if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { + New-Item -ItemType Directory -Path $repoCopy | Out-Null + Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse + & git -C $repoCopy init --quiet + & git -C $repoCopy config user.name 'Sense E2E' + & git -C $repoCopy config user.email 'sense-e2e@invalid.local' + & git -C $repoCopy commit --allow-empty --quiet -m 'temporary acceptance source' + + Write-Host 'Building Sense Windows package in an isolated temporary copy...' + & pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX + if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' } + $packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64' + } else { + $packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot) + if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' } + $senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..')) + Write-Host "Using prepared isolated package: $packageRoot" + } + + $pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8 + $script:baseUrl = "http://127.0.0.1:$sensePort" + Write-Host "Initializing isolated PostgreSQL on port $pgPort..." + & (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' } + # Do not synchronously invoke pg_ctl on Windows. Its persistent + # cmd/postgres child inherits console handles and can keep PowerShell + # waiting even after pg_ctl exits. Start it hidden, then poll the port. + $pgStartArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start" + [void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStartArguments -RedirectStandardOutput $pgCtlLog -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden -PassThru) + Wait-Tcp -Port $pgPort -Open $true + $pgStarted = $true + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U sense_e2e sense_e2e + if ($LASTEXITCODE -ne 0) { throw 'isolated Sense database creation failed' } + Write-Host 'Isolated PostgreSQL database is ready.' + + $mediaConfig = @( + 'logLevel: warn', 'api: true', "apiAddress: 127.0.0.1:$mediaAPIPort", + 'rtspTransports: [tcp]', "rtspAddress: 127.0.0.1:$rtspPort", "hlsAddress: 127.0.0.1:$hlsPort", + "webrtcAddress: 127.0.0.1:$webrtcPort", "webrtcLocalUDPAddress: 127.0.0.1:$webrtcUDPort", + 'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:' + ) -join "`n" + [IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false))) + + $jwt = New-RandomText 48 + $bootstrap = New-RandomText 48 + $adminPassword = New-RandomText 18 + $credentialBytes = New-Object byte[] 32 + $credentialGenerator = [Security.Cryptography.RandomNumberGenerator]::Create() + try { $credentialGenerator.GetBytes($credentialBytes) } finally { $credentialGenerator.Dispose() } + $credentialKey = [Convert]::ToBase64String($credentialBytes) + $cameraUser = 'fixture_' + (New-RandomText 8) + $cameraPassword = New-RandomText 24 + $database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable" + $environment = @{ + SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database; + SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap; + SENSE_CREDENTIAL_KEY = $credentialKey; SENSE_ONVIF_DISCOVERY_IP = '127.0.0.1'; + SENSE_ONVIF_ALLOWED_CIDRS = '127.0.0.0/8'; SENSE_MEDIAMTX_MODE = 'managed'; + SENSE_MEDIAMTX_BINARY = 'bin\mediamtx.exe'; SENSE_MEDIAMTX_CONFIG = 'config\mediamtx.yml'; + SENSE_MEDIAMTX_API = "http://127.0.0.1:$mediaAPIPort"; SENSE_WEB_ROOT = 'web'; + SENSE_AUTO_MIGRATE = 'true'; SENSE_POSTGRES_BIN = $PostgresBin; + SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE = "http://127.0.0.1:$webrtcPort" + } + foreach ($item in $environment.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value } + Write-Host 'Generated ephemeral runtime values without persisting credentials.' + + [IO.File]::WriteAllText($stateFile, 'initial', (New-Object Text.UTF8Encoding($false))) + foreach ($item in @{ + SENSE_E2E_ONVIF_PORT = "$onvifPort"; SENSE_E2E_RTSP_PORT = "$rtspPort"; + SENSE_E2E_CAMERA_USERNAME = $cameraUser; SENSE_E2E_CAMERA_PASSWORD = $cameraPassword; + SENSE_E2E_PROFILE_STATE_FILE = $stateFile; SENSE_E2E_FIXTURE_STATUS_FILE = $fixtureStatus + }.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value } + Write-Host "Starting Digest ONVIF fixture on port $onvifPort..." + $fixture = Start-Process -FilePath 'python.exe' -ArgumentList (Join-Path $PSScriptRoot '..\fixtures\onvif_digest_fixture.py') -RedirectStandardOutput $fixtureLog -RedirectStandardError $fixtureError -WindowStyle Hidden -PassThru + for ($attempt = 0; $attempt -lt 40 -and -not (Test-Path $fixtureStatus); $attempt++) { Start-Sleep -Milliseconds 250 } + if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' } + + $server = Start-SensePackage $packageRoot + Wait-Http "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" + $publisherArguments = @( + '-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10', + '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp', + "rtsp://127.0.0.1:$rtspPort/fixture" + ) + $publisher = Start-Process -FilePath $ffmpeg -ArgumentList $publisherArguments -RedirectStandardOutput $ffmpegLog -RedirectStandardError $ffmpegError -WindowStyle Hidden -PassThru + Start-Sleep -Seconds 2 + if ($publisher.HasExited) { throw 'synthetic RTSP publisher exited before the acceptance flow' } + + $bootstrapResponse = Invoke-SenseJson POST '/api/v1/bootstrap' @{ username = 'acceptance-admin'; password = $adminPassword; nickName = 'Acceptance Admin' } '' 403 + # Bootstrap token is a header, so use the dedicated request without placing it in a body or URI. + $bootstrapResponse = Invoke-RestMethod -Method POST -Uri "$baseUrl/api/v1/bootstrap" -Headers @{ 'X-Sense-Bootstrap-Token' = $bootstrap } -ContentType 'application/json; charset=utf-8' -Body (@{ username = 'acceptance-admin'; password = $adminPassword; nickName = 'Acceptance Admin' } | ConvertTo-Json -Compress) + if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' } + $login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword } + $token = [string]$login.token + if ($token.Length -lt 20) { throw 'login did not return a usable token' } + $unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401 + + $deviceBody = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\fixtures\device-create.zh-CN.json') -Raw -Encoding utf8 | ConvertFrom-Json + $created = Invoke-SenseJson POST '/api/v1/devices' $deviceBody $token + $device = $created.data + if ($device.name -ne $deviceBody.name -or $device.location -ne $deviceBody.location) { throw 'Chinese device fields did not round-trip' } + $badBody = @{ name = 'reject-unknown-field'; location = 'fixture'; modality = 'video'; capabilities = @('video'); password = $cameraPassword } + $bad = Invoke-SenseJson POST '/api/v1/devices' $badBody $token 400 + $credentials = Invoke-SenseJson PUT "/api/v1/devices/$($device.id)/credentials" @{ + onvifUsername = $cameraUser; onvifPassword = $cameraPassword; rtspSameAsOnvif = $false; + rtspUsername = $cameraUser; rtspPassword = $cameraPassword; version = [int64]$device.version + } $token + $device = $credentials.data + if (-not $device.onvifCredentialConfigured -or -not $device.rtspCredentialConfigured) { throw 'purpose-separated credentials were not recorded' } + $probe = Invoke-SenseJson POST "/api/v1/admission/devices/$($device.id)/probe" @{ address = "http://127.0.0.1:$onvifPort/onvif/device"; version = [int64]$device.version } $token + if ($probe.data.status -ne 'ready' -or $probe.data.profiles[0].verificationStatus -ne 'ready') { throw 'Digest ONVIF/RTSP probe did not become ready' } + $fixtureEvidence = Get-Content -LiteralPath $fixtureStatus -Raw -Encoding utf8 | ConvertFrom-Json + if ([int]$fixtureEvidence.digestRequests -lt 3) { throw 'ONVIF fixture did not observe Digest requests for the full SOAP flow' } + + $routeList = Invoke-SenseJson GET '/api/v1/media/routes' $null $token + $route = @($routeList.data.list)[0] + if (-not $route.id -or $route.path -match '(?i)@|password|credential') { throw 'media route is missing or exposes credentials' } + [void](Invoke-SenseJson POST "/api/v1/media/routes/$([Uri]::EscapeDataString($route.id))/reconcile" @{} $token) + $consumerOut = Join-Path $temporary 'consumer.out.log' + $consumerErr = Join-Path $temporary 'consumer.err.log' + $consumer = Start-Process -FilePath $ffmpeg -ArgumentList @( + '-hide_banner', '-loglevel', 'error', '-rtsp_transport', 'tcp', '-i', "rtsp://127.0.0.1:$rtspPort/$($route.path)", + '-t', '2', '-f', 'null', 'NUL' + ) -RedirectStandardOutput $consumerOut -RedirectStandardError $consumerErr -WindowStyle Hidden -PassThru -Wait + if ($consumer.ExitCode -ne 0) { throw 'on-demand MediaMTX route did not deliver the synthetic stream' } + + $liveRoutes = Invoke-SenseJson GET '/api/v1/liveview/routes?pageIndex=1&pageSize=10' $null $token + $liveRoute = @($liveRoutes.data.list)[0] + $session = Invoke-SenseJson POST '/api/v1/liveview/sessions' @{ routeId = $liveRoute.id } $token + $player = Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl$($session.data.playerUrl)" -TimeoutSec 10 + if ($player.StatusCode -ne 200 -or -not $player.Content.Contains(":$webrtcPort/")) { throw 'live-view wrapper did not use the safe browser-visible MediaMTX address' } + + $areaBody = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\fixtures\area-polygon.zh-CN.json') -Raw -Encoding utf8 | ConvertFrom-Json + $areaBody | Add-Member -NotePropertyName routeId -NotePropertyValue $route.id + $areaCreated = Invoke-SenseJson POST '/api/v1/area/configurations' $areaBody $token + if ($areaCreated.data.name -ne $areaBody.name) { throw 'Chinese area fields did not round-trip' } + + [IO.File]::WriteAllText($stateFile, 'changed', (New-Object Text.UTF8Encoding($false))) + $currentDevice = (Invoke-SenseJson GET "/api/v1/devices/$($device.id)" $null $token).data + $reprobe = Invoke-SenseJson POST "/api/v1/admission/devices/$($device.id)/probe" @{ address = "http://127.0.0.1:$onvifPort/onvif/device"; version = [int64]$currentDevice.version } $token + if ($reprobe.data.profiles[0].width -ne 1280) { throw 'changed profile resolution was not persisted' } + $areas = Invoke-SenseJson GET '/api/v1/area/configurations?pageIndex=1&pageSize=10' $null $token + $area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0] + if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' } + + $browserScript = Join-Path $senseCopy 'ui\sense-browser-smoke.cjs' + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript + foreach ($item in @{ + SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser; + SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png') + }.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value } + Push-Location (Join-Path $senseCopy 'ui') + try { & node.exe $browserScript } finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' } + + & (Join-Path $packageRoot 'stop-sense.bat') + Start-Sleep -Seconds 2 + if (-not $server.HasExited) { Stop-ProcessTree $server } + $server = Start-SensePackage $packageRoot + $routesAfterRestart = Invoke-SenseJson GET '/api/v1/media/routes' $null $token + if (@($routesAfterRestart.data.list).Count -lt 1) { throw 'cold restart lost persisted media routes' } + if ($publisher.HasExited) { + $publisher = Start-Process -FilePath $ffmpeg -ArgumentList $publisherArguments -RedirectStandardOutput $ffmpegLog -RedirectStandardError $ffmpegError -WindowStyle Hidden -PassThru + Start-Sleep -Seconds 2 + } + [void](Invoke-SenseJson POST "/api/v1/media/routes/$([Uri]::EscapeDataString($route.id))/reconcile" @{} $token) + $restartConsumer = Start-Process -FilePath $ffmpeg -ArgumentList @( + '-hide_banner', '-loglevel', 'error', '-rtsp_transport', 'tcp', '-i', "rtsp://127.0.0.1:$rtspPort/$($route.path)", + '-t', '2', '-f', 'null', 'NUL' + ) -RedirectStandardOutput (Join-Path $temporary 'restart-consumer.out.log') -RedirectStandardError (Join-Path $temporary 'restart-consumer.err.log') -WindowStyle Hidden -PassThru -Wait + if ($restartConsumer.ExitCode -ne 0) { throw 'cold restart did not restore on-demand playback' } + + $psql = Join-Path $PostgresBin 'psql.exe' + $migrationCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc 'select count(*) from sys_migration;').Trim() + $capabilitiesType = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select data_type from information_schema.columns where table_name='sense_devices' and column_name='capabilities';").Trim() + if ([int]$migrationCount -lt 8 -or $capabilitiesType -ne 'jsonb') { throw 'clean PostgreSQL migration chain did not reach the Sense schema' } + $plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim() + if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' } + + foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError)) { + if (Test-Path $log) { + $text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue) + if ($null -eq $text) { $text = '' } + if ($text.Contains($adminPassword) -or $text.Contains($cameraPassword) -or $text.Contains($token)) { throw "runtime log exposed acceptance credentials: $log" } + } + } + Write-Host 'Sense isolated E2E passed: clean PostgreSQL, login/RBAC, Chinese device, Digest ONVIF/RTSP, separated credentials, MediaMTX on-demand playback, live view, area recalibration, browser shell, cold restart and Windows stop.' +} finally { + Stop-ProcessTree $publisher + Stop-ProcessTree $fixture + Stop-ProcessTree $server + if ($pgStarted) { + $pgStopArguments = "-D `"$pgData`" -m fast stop" + [void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru) + 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 isolated acceptance directory: $temporary" + } +} diff --git a/Sense/tests/fixtures/area-polygon.zh-CN.json b/Sense/tests/fixtures/area-polygon.zh-CN.json new file mode 100644 index 0000000..c5bba7a --- /dev/null +++ b/Sense/tests/fixtures/area-polygon.zh-CN.json @@ -0,0 +1,13 @@ +{ + "name": "东门危险区域", + "kind": "polygon", + "points": [ + { "x": 0.12, "y": 0.18 }, + { "x": 0.82, "y": 0.18 }, + { "x": 0.76, "y": 0.78 }, + { "x": 0.18, "y": 0.72 } + ], + "direction": "", + "enabled": true, + "expectedVersion": 0 +} diff --git a/Sense/tests/fixtures/device-create.zh-CN.json b/Sense/tests/fixtures/device-create.zh-CN.json new file mode 100644 index 0000000..b9c2dfd --- /dev/null +++ b/Sense/tests/fixtures/device-create.zh-CN.json @@ -0,0 +1,8 @@ +{ + "name": "东门测试摄像机", + "location": "一号楼东门入口", + "modality": "video", + "capabilities": [ + "video" + ] +} diff --git a/Sense/tests/fixtures/onvif_digest_fixture.py b/Sense/tests/fixtures/onvif_digest_fixture.py new file mode 100644 index 0000000..5d51e9f --- /dev/null +++ b/Sense/tests/fixtures/onvif_digest_fixture.py @@ -0,0 +1,121 @@ +"""Isolated ONVIF fixture for Sense acceptance tests. + +Credentials are required through process environment and are never printed or +persisted. The fixture validates RFC 7616 MD5 qop=auth so the test exercises +Sense's real Digest retry instead of accepting an arbitrary header. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +HOST = "127.0.0.1" +PORT = int(os.environ["SENSE_E2E_ONVIF_PORT"]) +RTSP_PORT = int(os.environ["SENSE_E2E_RTSP_PORT"]) +USERNAME = os.environ["SENSE_E2E_CAMERA_USERNAME"] +PASSWORD = os.environ["SENSE_E2E_CAMERA_PASSWORD"] +STATE_PATH = Path(os.environ["SENSE_E2E_PROFILE_STATE_FILE"]) +STATUS_PATH = Path(os.environ["SENSE_E2E_FIXTURE_STATUS_FILE"]) +REALM = "sense-e2e-camera" +NONCE = hashlib.sha256(os.urandom(32)).hexdigest() + + +def _md5(value: str) -> str: + return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest() + + +def _parameters(value: str) -> dict[str, str]: + result: dict[str, str] = {} + for match in re.finditer(r'(\w+)=(?:"([^"]*)"|([^,\s]+))', value): + result[match.group(1).lower()] = match.group(2) or match.group(3) + return result + + +def _authorized(header: str, method: str, uri: str) -> bool: + if not header.startswith("Digest "): + return False + values = _parameters(header[7:]) + if values.get("username") != USERNAME or values.get("realm") != REALM: + return False + if values.get("nonce") != NONCE or values.get("uri") != uri: + return False + ha1 = _md5(f"{USERNAME}:{REALM}:{PASSWORD}") + ha2 = _md5(f"{method}:{uri}") + if values.get("qop") == "auth": + expected = _md5( + f"{ha1}:{NONCE}:{values.get('nc', '')}:{values.get('cnonce', '')}:auth:{ha2}" + ) + else: + expected = _md5(f"{ha1}:{NONCE}:{ha2}") + return values.get("response") == expected + + +def _write_status(digest_requests: int) -> None: + STATUS_PATH.write_text( + json.dumps({"ready": True, "digestRequests": digest_requests}), + encoding="utf-8", + ) + + +class Handler(BaseHTTPRequestHandler): + digest_requests = 0 + + def log_message(self, _format: str, *_args: object) -> None: + return + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + length = min(int(self.headers.get("Content-Length", "0")), 2 << 20) + body = self.rfile.read(length).decode("utf-8", errors="replace") + authorization = self.headers.get("Authorization", "") + if not _authorized(authorization, "POST", self.path): + self.send_response(401) + self.send_header( + "WWW-Authenticate", + f'Digest realm="{REALM}", nonce="{NONCE}", algorithm=MD5, qop="auth"', + ) + self.end_headers() + return + + type(self).digest_requests += 1 + _write_status(type(self).digest_requests) + state = STATE_PATH.read_text(encoding="utf-8").strip() + width, height = ((1280, 720) if state == "changed" else (1920, 1080)) + if "GetCapabilities" in body: + payload = ( + "" + f"http://{HOST}:{PORT}/onvif/media" + "" + ) + elif "GetProfiles" in body: + payload = ( + '' + "主码流H264" + f"{width}{height}" + "" + ) + elif "GetStreamUri" in body: + payload = ( + "" + f"rtsp://{HOST}:{RTSP_PORT}/fixture" + "" + ) + else: + self.send_error(400) + return + encoded = payload.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/soap+xml; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +if __name__ == "__main__": + _write_status(0) + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() -- 2.34.1