diff --git a/scripts/e2e/coordination/run-coordination-e2e.bat b/scripts/e2e/coordination/run-coordination-e2e.bat new file mode 100644 index 0000000..766c043 --- /dev/null +++ b/scripts/e2e/coordination/run-coordination-e2e.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh.exe -NoProfile -File "%~dp0run-coordination-e2e.ps1" %* +exit /b %ERRORLEVEL% diff --git a/scripts/e2e/coordination/run-coordination-e2e.ps1 b/scripts/e2e/coordination/run-coordination-e2e.ps1 new file mode 100644 index 0000000..69a9039 --- /dev/null +++ b/scripts/e2e/coordination/run-coordination-e2e.ps1 @@ -0,0 +1,191 @@ +[CmdletBinding()] +param( + [string]$PostgresBin = 'D:\pgsql17\bin', + [string]$Python = '', + [switch]$SkipIndependentProductE2E, + [switch]$KeepTemporary +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) +$temporaryRoot = [IO.Path]::GetFullPath((Join-Path ([IO.Path]::GetTempPath()) ('yovision-coordination-e2e-' + [guid]::NewGuid().ToString('N')))) +$postgresData = Join-Path $temporaryRoot 'postgres' +$postgresLog = Join-Path $temporaryRoot 'postgres.log' +$postgresStarted = $false +$savedEnvironment = @{} +$sensitiveValues = [Collections.Generic.List[string]]::new() + +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 New-RandomName([string]$Prefix) { + return $Prefix + '_' + [guid]::NewGuid().ToString('N').Substring(0, 12) +} + +function New-RandomSecret { + $buffer = New-Object byte[] 48 + $generator = [Security.Cryptography.RandomNumberGenerator]::Create() + try { $generator.GetBytes($buffer) } finally { $generator.Dispose() } + return [Convert]::ToBase64String($buffer).Replace('+', 'A').Replace('/', 'B') +} + +function Set-TestEnvironment([string]$Name, [string]$Value, [bool]$Sensitive = $false) { + if (-not $script:savedEnvironment.ContainsKey($Name)) { + $script:savedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, 'Process') + } + [Environment]::SetEnvironmentVariable($Name, $Value, 'Process') + if ($Sensitive) { $script:sensitiveValues.Add($Value) } +} + +function Invoke-Checked { + param([string]$Name, [string]$WorkingDirectory, [scriptblock]$Command) + Write-Host "[coordination-e2e] $Name" + Push-Location $WorkingDirectory + try { + & $Command + if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } + } finally { Pop-Location } +} + +function Assert-NoSecretInLogs { + $logs = @(Get-ChildItem -LiteralPath $temporaryRoot -File -Recurse -ErrorAction SilentlyContinue) + foreach ($log in $logs) { + $stream = [IO.File]::Open($log.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) + try { + $reader = [IO.StreamReader]::new($stream, [Text.Encoding]::UTF8, $true) + try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() } + } finally { $stream.Dispose() } + foreach ($secret in $sensitiveValues) { + if ($secret.Length -ge 8 -and $content.Contains($secret)) { + throw "Temporary log exposed a generated E2E secret: $($log.Name)" + } + } + } +} + +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + +try { + foreach ($tool in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) { + $path = Join-Path $PostgresBin $tool + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required PostgreSQL tool not found: $path" } + } + if ([string]::IsNullOrWhiteSpace($Python)) { + $candidate = Join-Path $repositoryRoot 'Brain\.venv\Scripts\python.exe' + $Python = if (Test-Path -LiteralPath $candidate -PathType Leaf) { $candidate } else { 'python.exe' } + } + $contractEnvironment = Join-Path $temporaryRoot 'contract-venv' + & $Python -m venv $contractEnvironment + if ($LASTEXITCODE -ne 0) { throw 'Could not create the isolated contract-test environment.' } + $contractPython = Join-Path $contractEnvironment 'Scripts\python.exe' + $env:PIP_DISABLE_PIP_VERSION_CHECK = '1' + & $contractPython -m pip install --quiet -r (Join-Path $repositoryRoot 'contracts\tests\source-config-v1\requirements.txt') 'cryptography==50.0.1' + if ($LASTEXITCODE -ne 0) { throw 'Could not install the pinned contract-test dependencies.' } + + $postgresPort = Get-FreeTcpPort + if ($postgresPort -eq 5432) { throw 'Coordination E2E refuses the default PostgreSQL port.' } + $clusterUser = New-RandomName 'yvcoord' + $senseRole = New-RandomName 'sense_owner' + $bellRole = New-RandomName 'bell_owner' + $senseDatabase = New-RandomName 'sense_e2e' + $bellDatabase = New-RandomName 'bell_e2e' + + & (Join-Path $PostgresBin 'initdb.exe') -D $postgresData -U $clusterUser -A trust --encoding=UTF8 --no-locale | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Isolated PostgreSQL initdb failed.' } + $startArguments = "-D `"$postgresData`" -l `"$postgresLog`" -o `"-p $postgresPort -h 127.0.0.1`" start" + Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $startArguments -RedirectStandardOutput (Join-Path $temporaryRoot 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporaryRoot 'pg-ctl.err.log') -WindowStyle Hidden | Out-Null + Wait-Tcp -Port $postgresPort -Open $true + $postgresStarted = $true + + $psql = Join-Path $PostgresBin 'psql.exe' + foreach ($role in @($senseRole, $bellRole)) { + & $psql -X -h 127.0.0.1 -p $postgresPort -U $clusterUser -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE $role LOGIN;" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Could not create isolated role $role" } + } + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $postgresPort -U $clusterUser -O $senseRole $senseDatabase + if ($LASTEXITCODE -ne 0) { throw 'Could not create isolated Sense database.' } + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $postgresPort -U $clusterUser -O $bellRole $bellDatabase + if ($LASTEXITCODE -ne 0) { throw 'Could not create isolated Bell database.' } + + $senseDsn = "host=127.0.0.1 port=$postgresPort user=$senseRole dbname=$senseDatabase sslmode=disable" + $bellDsn = "host=127.0.0.1 port=$postgresPort user=$bellRole dbname=$bellDatabase sslmode=disable" + if ($senseDsn -eq $bellDsn -or $senseRole -eq $bellRole -or $senseDatabase -eq $bellDatabase) { throw 'Sense and Bell isolation invariant failed.' } + + Set-TestEnvironment 'GOTOOLCHAIN' 'go1.26.5' + Set-TestEnvironment 'PYTHONDONTWRITEBYTECODE' '1' + Set-TestEnvironment 'SENSE_OUTBOX_TEST_DATABASE_URL' $senseDsn + Set-TestEnvironment 'BELL_DATABASE_URL' $bellDsn + Set-TestEnvironment 'BELL_EVENT_INGRESS_TEST_DATABASE_URL' $bellDsn + Set-TestEnvironment 'BELL_RULE_ALERT_TEST_DATABASE_URL' $bellDsn + Set-TestEnvironment 'BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL' $bellDsn + Set-TestEnvironment 'BELL_JWT_SECRET' (New-RandomSecret) $true + Set-TestEnvironment 'BELL_BOOTSTRAP_USERNAME' (New-RandomName 'coord_admin') + Set-TestEnvironment 'BELL_BOOTSTRAP_PASSWORD' (New-RandomSecret) $true + Set-TestEnvironment 'BELL_RULE_ALERT_OPERATOR_PASSWORD' (New-RandomSecret) $true + Set-TestEnvironment 'BELL_HOST' '127.0.0.1' + Set-TestEnvironment 'BELL_PORT' (Get-FreeTcpPort).ToString() + + Invoke-Checked 'Bell formal migrations' (Join-Path $repositoryRoot 'Bell\server') { go run . migrate -c config/settings.demo.yml *> (Join-Path $temporaryRoot 'bell-migrate.log') } + + Invoke-Checked 'source-config v1 contract' $repositoryRoot { & $contractPython -m unittest discover -s contracts/tests/source-config-v1 -p 'test_*.py' -v } + Invoke-Checked 'runtime-status v1 contract' $repositoryRoot { & $contractPython contracts/tests/runtime-status-v1/test_contract.py } + Invoke-Checked 'machine-identity v1 cross-language contract' $repositoryRoot { & $contractPython contracts/tests/machine-identity-v1/test_contract.py } + Invoke-Checked 'events v1 contract' $repositoryRoot { & $contractPython contracts/tests/events-v1/test_contract.py } + Invoke-Checked 'evidence v1 contract' $repositoryRoot { & $contractPython contracts/tests/evidence-v1/test_contract.py } + + Invoke-Checked 'Sense source/status integration' (Join-Path $repositoryRoot 'Sense\tests\integration\brain_control') { go test . -count=1 -v } + Invoke-Checked 'Sense Brain-event/evidence/Outbox integration' (Join-Path $repositoryRoot 'Sense\tests\integration\bell_connector') { go test . -count=1 -v } + Invoke-Checked 'Sense PostgreSQL Outbox recovery' (Join-Path $repositoryRoot 'Sense\server') { go test ./app/sense/outbox -count=1 -v } + Invoke-Checked 'Brain source/status connector and anonymous event export' $repositoryRoot { + & $Python -m pytest Brain/tests/integration/sense_control Brain/tests/integration/event_export -q + } + Invoke-Checked 'Bell ingress and evidence degradation' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/integration/event_ingress -count=1 -v } + Invoke-Checked 'Bell rule and alert projection' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/bell_rule_alert -count=1 -v } + Invoke-Checked 'Bell alert lifecycle' (Join-Path $repositoryRoot 'Bell\server') { go test ./tests/bell_alert_lifecycle -count=1 -v } + + $identityFacts = (& $psql -X -h 127.0.0.1 -p $postgresPort -U $clusterUser -d postgres -tAc "select datname||':'||pg_get_userbyid(datdba) from pg_database where datname in ('$senseDatabase','$bellDatabase') order by datname;") + if (@($identityFacts).Count -ne 2 -or ($identityFacts -join '|') -notmatch [regex]::Escape($senseRole) -or ($identityFacts -join '|') -notmatch [regex]::Escape($bellRole)) { + throw 'PostgreSQL ownership isolation evidence is incomplete.' + } + + if (-not $SkipIndependentProductE2E) { + Invoke-Checked 'Sense independent isolated E2E regression' $repositoryRoot { & (Join-Path $repositoryRoot 'Sense\tests\e2e\run-isolated-e2e.ps1') -PostgresBin $PostgresBin } + Invoke-Checked 'Brain independent test regression' $repositoryRoot { & $Python -m pytest Brain/tests -q } + Invoke-Checked 'Bell independent isolated E2E regression' $repositoryRoot { & (Join-Path $repositoryRoot 'Bell\tests\e2e\run-isolated-e2e.ps1') -PostgresBin $PostgresBin } + } + + Assert-NoSecretInLogs + Write-Host "COORDINATION_E2E passed: versioned contracts, source/status, anonymous event, durable Outbox recovery, Bell Receipt/Event/Alert lifecycle, identity/replay/conflict/evidence faults, separate Sense/Bell databases. postgres_port=$postgresPort" +} finally { + if ($postgresStarted) { + Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$postgresData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporaryRoot 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporaryRoot 'pg-stop.err.log') -WindowStyle Hidden -Wait | Out-Null + try { Wait-Tcp -Port $postgresPort -Open $false -Attempts 40 } catch {} + } + foreach ($entry in $savedEnvironment.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process') + } + if ($KeepTemporary) { + Write-Host "Kept coordination E2E directory: $temporaryRoot" + } elseif (Test-Path -LiteralPath $temporaryRoot) { + $resolved = [IO.Path]::GetFullPath($temporaryRoot) + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) + if (-not $resolved.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not ([IO.Path]::GetFileName($resolved)).StartsWith('yovision-coordination-e2e-')) { + throw "Refusing unsafe temporary cleanup: $resolved" + } + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} diff --git a/tests/e2e/coordination/README.md b/tests/e2e/coordination/README.md new file mode 100644 index 0000000..4f83bd2 --- /dev/null +++ b/tests/e2e/coordination/README.md @@ -0,0 +1,18 @@ +# Coordination E2E + +Run the complete isolated coordination acceptance from the repository root: + +```powershell +pwsh scripts/e2e/coordination/run-coordination-e2e.ps1 +``` + +The runner creates a temporary PostgreSQL cluster on a dynamic loopback port, +uses distinct random owners and databases for Sense and Bell, exercises the +frozen contracts and the three connector chains, runs each product's existing +independent regression, checks generated secrets are absent from temporary +logs, and removes only the processes and directory it created. + +`-SkipIndependentProductE2E` is intended only for local harness debugging and +does not satisfy issue #155 acceptance. `-KeepTemporary` preserves disposable +diagnostics after a failed run; the directory contains test-only generated +credentials and must not be committed or shared.