[CmdletBinding()] param() $ErrorActionPreference='Stop' $repo=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $dll=Join-Path $repo 'tests/QVPN.Tests/bin/Release/net10.0-windows/QVPN.Tests.dll' $root=Join-Path $env:ProgramFiles 'QVPN' $report=Join-Path $repo 'research/installed-recovery-verification.json' $checks=[Collections.Generic.List[object]]::new() function Status { $raw=& dotnet $dll --client status if($raw -notmatch '^\{'){throw 'Status endpoint not available'} $response=$raw|ConvertFrom-Json if(!$response.Ok){throw 'Status endpoint returned failure'} return $response.Status } function Wait-Recovered([int]$oldEngine,[int]$oldService=0) { $timer=[Diagnostics.Stopwatch]::StartNew() do { Start-Sleep -Seconds 2 try { $s=Status $service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'" if($s.State -eq 'connected' -and $s.DesiredConnected -and $s.EnginePid -and $s.EnginePid -ne $oldEngine -and (!$oldService -or $service.ProcessId -ne $oldService)){return $s} }catch{} }while($timer.Elapsed.TotalSeconds -lt 60) throw 'Automatic recovery did not complete within 60 seconds' } try { $s=Status if($s.State -ne 'connected' -or !$s.DesiredConnected){throw 'QVPN must already be connected for this test'} $oldEngine=$s.EnginePid $p=Get-Process -Id $oldEngine if($p.Path -ne (Join-Path $root 'core/sing-box.exe')){throw 'Unexpected engine executable'} $timer=[Diagnostics.Stopwatch]::StartNew() Stop-Process -Id $oldEngine -Force $s=Wait-Recovered $oldEngine $checks.Add([pscustomobject]@{Scenario='Installed TUN engine crash recovery';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);Restarts=$s.Restarts;KeyEnteredAgain=$false}) $oldEngine=$s.EnginePid $service=Get-CimInstance Win32_Service -Filter "Name='QVPNService'" $p=Get-Process -Id $service.ProcessId if($p.Path -ne (Join-Path $root 'service/QVPN.Service.exe')){throw 'Unexpected service executable'} $oldService=$p.Id $timer.Restart() Stop-Process -Id $oldService -Force Start-Sleep -Seconds 2 $orphan=Get-Process -Id $oldEngine -ErrorAction SilentlyContinue if($orphan -and $orphan.Path -eq (Join-Path $root 'core/sing-box.exe')){throw 'Orphan engine survived service crash'} $s=Wait-Recovered $oldEngine $oldService $checks.Add([pscustomobject]@{Scenario='Installed SCM crash recovery and persisted intent/key';Passed=$true;Seconds=[Math]::Round($timer.Elapsed.TotalSeconds,1);OrphanEngine=$false;Nodes=$s.Nodes.Count}) $http=& curl.exe --silent --show-error --noproxy '*' --max-time 15 --output NUL --write-out '%{http_code}' https://cp.cloudflare.com/generate_204 if($LASTEXITCODE -ne 0 -or $http -ne '204'){throw 'System HTTPS check failed after recovery'} $checks.Add([pscustomobject]@{Scenario='System HTTPS after both crashes';Passed=$true;HttpCode=$http}) [pscustomobject]@{Completed=$true;At=[DateTimeOffset]::Now.ToString('o');Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8 }catch{ [pscustomobject]@{Completed=$false;At=[DateTimeOffset]::Now.ToString('o');Failure=$_.Exception.GetType().Name+': '+$_.Exception.Message;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $report -Encoding utf8 exit 1 }